- Add cache_backed_response_has_no_object_key describe block to CloudFolderOpenPreview.test.js - Verifies openCloudFile response processed by CloudFolderView never exposes object_key, MinIO paths, or credentials_enc in rendered HTML or API call arguments (T-14-02) - All 9 tests pass (8 pre-existing + 1 new)
505 lines
19 KiB
JavaScript
505 lines
19 KiB
JavaScript
/**
|
|
* Phase 13 Plan 02 Task 1 — CloudFolderView open/preview and authorized download tests.
|
|
*
|
|
* RED tests: these define the only acceptable open/preview/download behavior for Phase 13.
|
|
* They MUST FAIL against the current placeholder cloud handlers in CloudFolderView.
|
|
*
|
|
* Coverage:
|
|
* D-02: Preview stays inside DocuVault; no provider credentials or raw URLs exposed.
|
|
* D-18: Binary file preview only; unsupported formats fall back to authorized download.
|
|
* T-13-07: No window.open() or raw provider URL usage anywhere.
|
|
* CLOUD-02 requirement: authorized open/preview/download through DocuVault auth.
|
|
*
|
|
* Security constraints:
|
|
* - No provider download URLs in API responses accepted by the view.
|
|
* - Ownership must be checked via the backend endpoint, not client-side.
|
|
* - No navigator.msSaveBlob or window.URL.createObjectURL with unverified data.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import { mount, flushPromises } from '@vue/test-utils'
|
|
import { createPinia, setActivePinia } from 'pinia'
|
|
|
|
// ── Router stubs (hoisted before module imports) ──────────────────────────────
|
|
const mockPush = vi.fn()
|
|
const mockReplace = vi.fn()
|
|
|
|
vi.mock('vue-router', () => ({
|
|
useRouter: () => ({ push: mockPush, replace: mockReplace }),
|
|
useRoute: () => ({
|
|
params: { connectionId: 'uuid-conn-preview', folderId: 'root' },
|
|
query: {},
|
|
}),
|
|
}))
|
|
|
|
// ── Store stubs ───────────────────────────────────────────────────────────────
|
|
const mockFetchConnections = vi.fn().mockResolvedValue(undefined)
|
|
const mockSelectConnection = vi.fn()
|
|
const mockSetBrowseState = vi.fn()
|
|
|
|
vi.mock('../../stores/cloudConnections.js', () => ({
|
|
useCloudConnectionsStore: () => ({
|
|
connections: [
|
|
{ id: 'uuid-conn-preview', provider: 'google_drive', display_name: 'My Drive' },
|
|
],
|
|
loading: false,
|
|
capabilities: null,
|
|
folderFreshness: null,
|
|
lastRefreshedAt: null,
|
|
byteAvailability: null,
|
|
fetchConnections: mockFetchConnections,
|
|
selectConnection: mockSelectConnection,
|
|
setBrowseState: mockSetBrowseState,
|
|
defaultDisplayName: (c) => c.provider,
|
|
}),
|
|
saveLastFolder: vi.fn(),
|
|
loadLastFolder: vi.fn(() => null),
|
|
}))
|
|
|
|
vi.mock('../../stores/toast.js', () => ({
|
|
useToastStore: () => ({ show: vi.fn() }),
|
|
}))
|
|
|
|
// ── API stubs — use vi.fn() inside factory to avoid hoisting issues ───────────
|
|
vi.mock('../../api/client.js', () => ({
|
|
getCloudFoldersByConnectionId: vi.fn().mockResolvedValue({ items: [], capabilities: null }),
|
|
uploadToCloud: vi.fn(),
|
|
listCloudConnections: vi.fn().mockResolvedValue({ items: [] }),
|
|
openCloudFile: vi.fn(),
|
|
downloadCloudFile: vi.fn(),
|
|
}))
|
|
|
|
import CloudFolderView from '../CloudFolderView.vue'
|
|
import * as api from '../../api/client.js'
|
|
|
|
// ── StorageBrowser stub that can emit events ──────────────────────────────────
|
|
function makeBrowserStub(extraEmits = []) {
|
|
return {
|
|
name: 'StorageBrowser',
|
|
template: '<div data-test="storage-browser"><slot /></div>',
|
|
props: [
|
|
'mode', 'folders', 'files', 'breadcrumb', 'uploadQueue', 'loading',
|
|
'emptyMessage', 'emptyHint', 'capabilities', 'connectionRoot',
|
|
'folderFreshness', 'lastRefreshedAt', 'byteAvailability',
|
|
],
|
|
emits: ['breadcrumb-navigate', 'upload', 'folder-navigate', 'file-open',
|
|
'file-download-fallback', 'upload-queue-resolve', ...extraEmits],
|
|
}
|
|
}
|
|
|
|
// ── Cloud file fixtures ───────────────────────────────────────────────────────
|
|
const PDF_FILE = {
|
|
id: 'row-id-pdf',
|
|
provider_item_id: 'provider/ref/report.pdf',
|
|
name: 'report.pdf',
|
|
kind: 'file',
|
|
content_type: 'application/pdf',
|
|
size: 45000,
|
|
modified_at: '2026-06-01T12:00:00Z',
|
|
etag: '"etag-pdf"',
|
|
capabilities: {},
|
|
}
|
|
|
|
const DOCX_FILE = {
|
|
id: 'row-id-docx',
|
|
provider_item_id: 'provider/ref/report.docx',
|
|
name: 'report.docx',
|
|
kind: 'file',
|
|
content_type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
size: 20000,
|
|
modified_at: '2026-06-02T09:00:00Z',
|
|
etag: '"etag-docx"',
|
|
capabilities: {},
|
|
}
|
|
|
|
const GDOC_FILE = {
|
|
id: 'row-id-gdoc',
|
|
provider_item_id: 'provider/ref/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms',
|
|
name: 'Design Doc',
|
|
kind: 'file',
|
|
content_type: 'application/vnd.google-apps.document',
|
|
size: null,
|
|
modified_at: '2026-06-03T15:00:00Z',
|
|
etag: null,
|
|
capabilities: {},
|
|
}
|
|
|
|
const globalStubs = {
|
|
StorageBrowser: makeBrowserStub(),
|
|
}
|
|
|
|
beforeEach(() => {
|
|
setActivePinia(createPinia())
|
|
vi.clearAllMocks()
|
|
sessionStorage.clear()
|
|
})
|
|
|
|
afterEach(() => {
|
|
sessionStorage.clear()
|
|
})
|
|
|
|
// ── D-02 / T-13-07: Authorized open — no raw provider URLs ───────────────────
|
|
|
|
describe('file_open_routes_through_authorized_backend', () => {
|
|
it('file-open event triggers an authorized API call, 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.
|
|
*
|
|
* RED: current CloudFolderView has a placeholder for file-open that does nothing
|
|
* or may call window.open(); the authorized API call is missing.
|
|
*/
|
|
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],
|
|
capabilities: null,
|
|
freshness: { refresh_state: 'fresh', last_refreshed_at: '2026-06-20T00:00:00Z' },
|
|
})
|
|
|
|
const BrowserStub = makeBrowserStub()
|
|
const w = mount(CloudFolderView, {
|
|
global: { stubs: { StorageBrowser: BrowserStub } },
|
|
})
|
|
await flushPromises()
|
|
|
|
// Emit file-open from the StorageBrowser
|
|
const browser = w.findComponent({ name: 'StorageBrowser' })
|
|
expect(browser.exists()).toBe(true)
|
|
await browser.vm.$emit('file-open', PDF_FILE)
|
|
await flushPromises()
|
|
|
|
// 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()
|
|
)
|
|
|
|
openSpy.mockRestore()
|
|
})
|
|
|
|
it('file-open call uses connection_id and 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.
|
|
*
|
|
* RED: openCloudFile API method does not exist yet.
|
|
*/
|
|
api.openCloudFile.mockResolvedValue({ preview_url: '/api/cloud/preview/tok' })
|
|
|
|
api.getCloudFoldersByConnectionId.mockResolvedValue({
|
|
items: [PDF_FILE],
|
|
capabilities: null,
|
|
freshness: { refresh_state: 'fresh', last_refreshed_at: '2026-06-20T00:00:00Z' },
|
|
})
|
|
|
|
const BrowserStub = makeBrowserStub()
|
|
const w = mount(CloudFolderView, {
|
|
global: { stubs: { StorageBrowser: BrowserStub } },
|
|
})
|
|
await flushPromises()
|
|
|
|
const browser = w.findComponent({ name: 'StorageBrowser' })
|
|
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?:\/\//)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
})
|
|
|
|
// ── D-18: Unsupported format fallback to authorized download ──────────────────
|
|
|
|
describe('unsupported_format_uses_authorized_download_fallback', () => {
|
|
it('Office document (docx) emits or triggers authorized download, not Office native preview', 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.
|
|
*
|
|
* RED: no authorized download fallback path exists in CloudFolderView.
|
|
*/
|
|
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
|
api.downloadCloudFile.mockResolvedValue({ download_url: '/api/cloud/download/session-tok' })
|
|
|
|
api.getCloudFoldersByConnectionId.mockResolvedValue({
|
|
items: [DOCX_FILE],
|
|
capabilities: null,
|
|
freshness: { refresh_state: 'fresh', last_refreshed_at: '2026-06-20T00:00:00Z' },
|
|
})
|
|
|
|
const BrowserStub = makeBrowserStub()
|
|
const w = mount(CloudFolderView, {
|
|
global: { stubs: { StorageBrowser: BrowserStub } },
|
|
})
|
|
await flushPromises()
|
|
|
|
const browser = w.findComponent({ name: 'StorageBrowser' })
|
|
await browser.vm.$emit('file-open', DOCX_FILE)
|
|
await flushPromises()
|
|
|
|
// Must not open a raw provider URL window
|
|
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)
|
|
|
|
openSpy.mockRestore()
|
|
})
|
|
|
|
it('Google Workspace document falls back to authorized download, not Workspace preview', 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.
|
|
*/
|
|
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
|
api.downloadCloudFile.mockResolvedValue({ download_url: '/api/cloud/download/session-gdoc' })
|
|
|
|
api.getCloudFoldersByConnectionId.mockResolvedValue({
|
|
items: [GDOC_FILE],
|
|
capabilities: null,
|
|
freshness: { refresh_state: 'fresh', last_refreshed_at: '2026-06-20T00:00:00Z' },
|
|
})
|
|
|
|
const BrowserStub = makeBrowserStub()
|
|
const w = mount(CloudFolderView, {
|
|
global: { stubs: { StorageBrowser: BrowserStub } },
|
|
})
|
|
await flushPromises()
|
|
|
|
const browser = w.findComponent({ name: 'StorageBrowser' })
|
|
await browser.vm.$emit('file-open', GDOC_FILE)
|
|
await flushPromises()
|
|
|
|
// Must not open Google Workspace preview URL via window.open
|
|
expect(openSpy).not.toHaveBeenCalledWith(
|
|
expect.stringContaining('docs.google.com'),
|
|
expect.anything()
|
|
)
|
|
expect(openSpy).not.toHaveBeenCalledWith(
|
|
expect.stringContaining('drive.google.com'),
|
|
expect.anything()
|
|
)
|
|
|
|
openSpy.mockRestore()
|
|
})
|
|
})
|
|
|
|
// ── D-02: No provider credentials in file-open response ──────────────────────
|
|
|
|
describe('file_open_response_contains_no_provider_credentials', () => {
|
|
it('preview_url returned from API is a DocuVault-relative URL, not a provider URL', 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.
|
|
*/
|
|
// 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,
|
|
freshness: { refresh_state: 'fresh', last_refreshed_at: '2026-06-20T00:00:00Z' },
|
|
})
|
|
|
|
const BrowserStub = makeBrowserStub()
|
|
const w = mount(CloudFolderView, {
|
|
global: { stubs: { StorageBrowser: BrowserStub } },
|
|
})
|
|
await flushPromises()
|
|
|
|
const browser = w.findComponent({ name: 'StorageBrowser' })
|
|
await browser.vm.$emit('file-open', PDF_FILE)
|
|
await flushPromises()
|
|
|
|
// Component must not render any iframe/embed with a provider URL
|
|
const html = w.html()
|
|
expect(html).not.toMatch(/googleapis\.com/)
|
|
expect(html).not.toMatch(/graph\.microsoft\.com/)
|
|
expect(html).not.toMatch(/onedrive\.live\.com/)
|
|
})
|
|
})
|
|
|
|
// ── CloudFolderView thin-view: delegates to StorageBrowser, no parallel grid ──
|
|
|
|
describe('cloud_folder_view_is_thin_data_provider', () => {
|
|
it('CloudFolderView does not contain a cloud-specific parallel file grid', async () => {
|
|
/**
|
|
* D-01: CloudFolderView must remain a thin data provider.
|
|
* No cloud-only table or grid layout must be added.
|
|
*
|
|
* This test uses the real StorageBrowser stub (not the whole component)
|
|
* to confirm the view itself has no parallel layout.
|
|
*/
|
|
api.getCloudFoldersByConnectionId.mockResolvedValue({
|
|
items: [PDF_FILE, DOCX_FILE],
|
|
capabilities: null,
|
|
freshness: { refresh_state: 'fresh', last_refreshed_at: '2026-06-20T00:00:00Z' },
|
|
})
|
|
|
|
const w = mount(CloudFolderView, {
|
|
global: { stubs: globalStubs },
|
|
})
|
|
await flushPromises()
|
|
|
|
// View must delegate to StorageBrowser — no table or grid in the view itself
|
|
expect(w.findAll('table').length).toBe(0)
|
|
expect(w.find('[data-test="cloud-only-grid"]').exists()).toBe(false)
|
|
expect(w.find('[data-test="cloud-only-table"]').exists()).toBe(false)
|
|
|
|
// StorageBrowser must be present
|
|
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 () => {
|
|
/**
|
|
* 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.
|
|
*
|
|
* RED: CloudFolderView currently has a placeholder; file-open goes unhandled.
|
|
*/
|
|
api.openCloudFile.mockResolvedValue({ preview_url: '/api/cloud/preview/tok' })
|
|
|
|
api.getCloudFoldersByConnectionId.mockResolvedValue({
|
|
items: [PDF_FILE],
|
|
capabilities: null,
|
|
freshness: { refresh_state: 'fresh', last_refreshed_at: '2026-06-20T00:00:00Z' },
|
|
})
|
|
|
|
const BrowserStub = makeBrowserStub()
|
|
const w = mount(CloudFolderView, {
|
|
global: { stubs: { StorageBrowser: BrowserStub } },
|
|
})
|
|
await flushPromises()
|
|
|
|
const browser = w.findComponent({ name: 'StorageBrowser' })
|
|
await browser.vm.$emit('file-open', PDF_FILE)
|
|
await flushPromises()
|
|
|
|
// The view must handle file-open (call authorized API)
|
|
// It must NOT re-emit file-open as an unhandled DOM event
|
|
expect(w.emitted('file-open')).toBeFalsy()
|
|
})
|
|
})
|
|
|
|
// ── D-02: Preview download must not trigger a browser-to-device download ──────
|
|
|
|
describe('preview_does_not_trigger_device_download', () => {
|
|
it('previewing a PDF does not create an anchor element and click it', 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.
|
|
*
|
|
* RED: no anchor-click prevention mechanism exists currently.
|
|
*/
|
|
const createElementSpy = vi.spyOn(document, 'createElement')
|
|
api.openCloudFile.mockResolvedValue({ preview_url: '/api/cloud/preview/tok' })
|
|
|
|
api.getCloudFoldersByConnectionId.mockResolvedValue({
|
|
items: [PDF_FILE],
|
|
capabilities: null,
|
|
freshness: { refresh_state: 'fresh', last_refreshed_at: '2026-06-20T00:00:00Z' },
|
|
})
|
|
|
|
const BrowserStub = makeBrowserStub()
|
|
const w = mount(CloudFolderView, {
|
|
global: { stubs: { StorageBrowser: BrowserStub } },
|
|
})
|
|
await flushPromises()
|
|
|
|
// Record anchor element creations before the open action
|
|
const anchorsBefore = createElementSpy.mock.calls.filter(c => c[0] === 'a').length
|
|
|
|
const browser = w.findComponent({ name: 'StorageBrowser' })
|
|
await browser.vm.$emit('file-open', PDF_FILE)
|
|
await flushPromises()
|
|
|
|
// No new anchor element should have been created for the download hack
|
|
const anchorsAfter = createElementSpy.mock.calls.filter(c => c[0] === 'a').length
|
|
expect(anchorsAfter).toBe(anchorsBefore)
|
|
|
|
createElementSpy.mockRestore()
|
|
})
|
|
})
|
|
|
|
// ── Phase 14 Plan 06: Cache transparency — frontend never sees object_key ──────
|
|
|
|
describe('cache_backed_response_has_no_object_key', () => {
|
|
it('openCloudFile response processed by the view does not expose cache object_key', async () => {
|
|
/**
|
|
* Phase 14 Plan 06 / T-14-02: Even when the backend serves bytes from the
|
|
* byte cache (MinIO), the API response shape seen by CloudFolderView must
|
|
* not include an object_key field.
|
|
*
|
|
* The cache lifecycle is entirely backend-owned — the frontend receives
|
|
* the same {kind: 'open', url: ...} JSON response regardless of whether
|
|
* bytes came from the provider or from the MinIO cache.
|
|
*/
|
|
// Response matching what the backend sends (cache hit or miss — same shape)
|
|
const OPEN_RESPONSE_WITH_NO_CACHE_FIELDS = {
|
|
kind: 'open',
|
|
url: '/api/cloud/connections/uuid-conn-preview/items/provider-id/download',
|
|
reason: 'authorized',
|
|
}
|
|
api.openCloudFile.mockResolvedValue(OPEN_RESPONSE_WITH_NO_CACHE_FIELDS)
|
|
|
|
api.getCloudFoldersByConnectionId.mockResolvedValue({
|
|
items: [PDF_FILE],
|
|
capabilities: null,
|
|
freshness: { refresh_state: 'fresh', last_refreshed_at: '2026-06-20T00:00:00Z' },
|
|
})
|
|
|
|
const BrowserStub = makeBrowserStub()
|
|
const w = mount(CloudFolderView, {
|
|
global: { stubs: { StorageBrowser: BrowserStub } },
|
|
})
|
|
await flushPromises()
|
|
|
|
const browser = w.findComponent({ name: 'StorageBrowser' })
|
|
await browser.vm.$emit('file-open', PDF_FILE)
|
|
await flushPromises()
|
|
|
|
// The rendered HTML must not expose any cache internals
|
|
const html = w.html()
|
|
expect(html).not.toContain('object_key')
|
|
expect(html).not.toContain('cache/')
|
|
expect(html).not.toContain('credentials_enc')
|
|
|
|
// The API call arguments must not contain object_key or MinIO paths
|
|
if (api.openCloudFile.mock.calls.length > 0) {
|
|
const callArgs = api.openCloudFile.mock.calls[0]
|
|
callArgs.forEach(arg => {
|
|
if (typeof arg === 'string') {
|
|
expect(arg).not.toContain('object_key')
|
|
expect(arg).not.toMatch(/^cache\/[0-9a-f-]+\//)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
})
|