/** * Phase 12.1 Plan 04 Task 3 — CloudFolderView rendered-flow integration test. * * Mounts the real CloudFolderView with the real StorageBrowser and BreadcrumbBar. * Only network boundaries (api/client.js) and router are stubbed. This validates * that the full render path from API response through view → StorageBrowser → * BreadcrumbBar operates correctly with the normalized API shape. * * Scenarios covered: * 1. Mixed root folders/files render through StorageBrowser (not a parallel grid) * 2. Click a folder whose opaque reference contains reserved characters — navigates with * provider_item_id intact; breadcrumb updated * 3. Navigate back via lineage breadcrumbs — breadcrumb trims to clicked node * 4. Refreshing → warning server freshness state renders with cached rows visible * 5. Fresh server state renders without a warning indicator * * Security constraints (T-12.1-16/17/18/20): * - No provider credentials, full URLs, or unexpected provider names in test fixtures * - Provider names appear only as generic stubs; no real Nextcloud/cloud data */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { mount, flushPromises } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' // ── Router stubs ───────────────────────────────────────────────────────────── const mockPush = vi.fn() const mockReplace = vi.fn() vi.mock('vue-router', () => ({ useRouter: () => ({ push: mockPush, replace: mockReplace }), useRoute: () => ({ params: { connectionId: 'conn-rendered-1', folderId: 'root' }, query: {}, }), })) // ── Store stubs ─────────────────────────────────────────────────────────────── const mockSetBrowseState = vi.fn() const mockSelectConnection = vi.fn() const mockFetchConnections = vi.fn().mockResolvedValue(undefined) vi.mock('../../stores/cloudConnections.js', () => ({ useCloudConnectionsStore: () => ({ connections: [ { id: 'conn-rendered-1', provider: 'webdav', display_name: 'Test WebDAV' }, ], 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() }), })) // Network boundary: stub only the API layer vi.mock('../../api/client.js', () => ({ getCloudFoldersByConnectionId: vi.fn().mockResolvedValue({ items: [], capabilities: null }), uploadToCloud: vi.fn(), listCloudConnections: vi.fn().mockResolvedValue({ items: [] }), })) import CloudFolderView from '../CloudFolderView.vue' import * as api from '../../api/client.js' // ── Stub leaf components that are not under test ───────────────────────────── // StorageBrowser and BreadcrumbBar are rendered REAL (not stubbed). // Leaf icon/search/sort components are stubbed to keep test output clean. const leafStubs = { AppIcon: { template: '' }, SearchBar: { template: '
' }, SortControls: { template: '
' }, DropZone: { template: '
', props: ['disabled'], emits: ['drop'], }, UploadProgress: { template: '
' }, TopicBadge: { template: '' }, EmptyState: { template: '
' }, } // ── Fixture items ───────────────────────────────────────────────────────────── /** Root folders/files from a generic WebDAV listing (no real provider content) */ const ROOT_FOLDER_A = { id: 'row-id-folder-alpha', provider_item_id: 'dav/folder/Alpha', name: 'Alpha', kind: 'folder', parent_ref: null, content_type: null, size: null, modified_at: null, etag: null, capabilities: {}, } /** Opaque provider reference with reserved characters (/, ?, #, spaces, Unicode) */ const ROOT_FOLDER_OPAQUE = { id: 'row-id-folder-opaque', provider_item_id: 'dav/path?q=1&r=2#frag/with spaces/日本語', name: 'Opaque Ref Folder', kind: 'folder', parent_ref: null, content_type: null, size: null, modified_at: null, etag: null, capabilities: {}, } const ROOT_FILE_A = { id: 'row-id-file-a', provider_item_id: 'dav/file/report.pdf', name: 'report.pdf', kind: 'file', parent_ref: null, content_type: 'application/pdf', size: 45000, modified_at: '2026-06-01T12:00:00Z', etag: '"etag-report"', capabilities: {}, } const ROOT_FILE_B = { id: 'row-id-file-b', provider_item_id: 'dav/file/notes.txt', name: 'notes.txt', kind: 'file', parent_ref: null, content_type: 'text/plain', size: 120, modified_at: '2026-06-15T09:00:00Z', etag: '"etag-notes"', capabilities: {}, } const FRESH_FRESHNESS = { refresh_state: 'fresh', last_refreshed_at: '2026-06-20T08:00:00Z', } const WARNING_FRESHNESS = { refresh_state: 'warning', last_refreshed_at: '2026-06-18T14:00:00Z', error_code: 'incomplete_listing', error_message: 'Provider returned incomplete results.', } // ── Test setup ──────────────────────────────────────────────────────────────── beforeEach(() => { setActivePinia(createPinia()) vi.clearAllMocks() sessionStorage.clear() }) afterEach(() => { sessionStorage.clear() }) // ── Test 1: Mixed root renders through real StorageBrowser ──────────────────── describe('rendered_root_uses_storage_browser', () => { it('renders folders and files through the real StorageBrowser, not a parallel grid', async () => { api.getCloudFoldersByConnectionId.mockResolvedValue({ items: [ROOT_FOLDER_A, ROOT_FOLDER_OPAQUE, ROOT_FILE_A, ROOT_FILE_B], capabilities: null, freshness: FRESH_FRESHNESS, }) const wrapper = mount(CloudFolderView, { global: { stubs: leafStubs }, }) await flushPromises() // StorageBrowser must be in the DOM (real component, not a stub) // We detect it by the BreadcrumbBar that it always renders const breadcrumbNav = wrapper.find('nav[aria-label="Navigation"]') expect(breadcrumbNav.exists()).toBe(true) // No parallel table or grid — only StorageBrowser's layout // StorageBrowser renders a list of folder/file rows const html = wrapper.html() // Folder names must be rendered expect(html).toContain('Alpha') expect(html).toContain('Opaque Ref Folder') // File names must be rendered expect(html).toContain('report.pdf') expect(html).toContain('notes.txt') // No raw credentials, hostnames, or unexpected provider data expect(html).not.toContain('NEXTCLOUD') expect(html).not.toContain('credentials_enc') expect(html).not.toContain('password') }) }) // ── Test 2: Click opaque folder — navigation preserves reserved chars ───────── describe('click_opaque_folder_navigates_with_intact_provider_item_id', () => { it('emitting folder-navigate with opaque ref pushes route with intact provider_item_id', async () => { api.getCloudFoldersByConnectionId.mockResolvedValue({ items: [ROOT_FOLDER_OPAQUE], capabilities: null, freshness: FRESH_FRESHNESS, }) const wrapper = mount(CloudFolderView, { global: { stubs: leafStubs }, }) await flushPromises() // Find all clickable rows (folder rows rendered by StorageBrowser) // StorageBrowser renders folders as rows with a button/clickable area const folderRows = wrapper.findAll('button[data-test="folder-row"], [data-kind="folder"], .folder-row, [role="row"]') // If StorageBrowser renders via event-based pattern, emit directly // Find the real StorageBrowser instance and emit folder-navigate const storageBrowser = wrapper.findComponent({ name: 'StorageBrowser' }) expect(storageBrowser.exists()).toBe(true) // Trigger folder navigation via the StorageBrowser emit await storageBrowser.vm.$emit('folder-navigate', ROOT_FOLDER_OPAQUE) await flushPromises() // router.push must have been called with the opaque provider_item_id intact expect(mockPush).toHaveBeenCalled() const hasPid = mockPush.mock.calls.some(call => { const arg = call[0] if (typeof arg === 'string') return arg.includes(ROOT_FOLDER_OPAQUE.provider_item_id) if (typeof arg === 'object') { const paramValues = Object.values(arg.params ?? {}) return paramValues.some(v => String(v) === ROOT_FOLDER_OPAQUE.provider_item_id) } return false }) expect(hasPid).toBe(true) // Must NOT use the DocuVault stable id const hasRowId = mockPush.mock.calls.some(call => { const arg = call[0] if (typeof arg === 'string') return arg.includes(ROOT_FOLDER_OPAQUE.id) if (typeof arg === 'object') { const paramValues = Object.values(arg.params ?? {}) return paramValues.some(v => String(v) === ROOT_FOLDER_OPAQUE.id) } return false }) expect(hasRowId).toBe(false) }) }) // ── Test 3: Navigate back via lineage breadcrumbs ───────────────────────────── describe('breadcrumb_lineage_navigate_back', () => { it('breadcrumb-navigate event for null navigates to root and clears lineage', async () => { api.getCloudFoldersByConnectionId.mockResolvedValue({ items: [ROOT_FOLDER_A], capabilities: null, freshness: FRESH_FRESHNESS, }) const wrapper = mount(CloudFolderView, { global: { stubs: leafStubs }, }) await flushPromises() // Build lineage via folder-navigate const storageBrowser = wrapper.findComponent({ name: 'StorageBrowser' }) expect(storageBrowser.exists()).toBe(true) // Navigate into a folder to build lineage await storageBrowser.vm.$emit('folder-navigate', ROOT_FOLDER_A) await flushPromises() // Now navigate back to root via breadcrumb-navigate(null) mockPush.mockClear() await storageBrowser.vm.$emit('breadcrumb-navigate', null) await flushPromises() // Router must push to root expect(mockPush).toHaveBeenCalled() const rootCall = mockPush.mock.calls.find(call => { const arg = call[0] if (typeof arg === 'string') return arg.includes('root') if (typeof arg === 'object') { return (arg.params?.folderId === 'root') || String(arg).includes('root') } return false }) expect(rootCall).toBeDefined() }) it('breadcrumb renders connection name as root label', async () => { api.getCloudFoldersByConnectionId.mockResolvedValue({ items: [], capabilities: null, freshness: FRESH_FRESHNESS, }) const wrapper = mount(CloudFolderView, { global: { stubs: leafStubs }, }) await flushPromises() // BreadcrumbBar renders the connection root name const breadcrumbNav = wrapper.find('nav[aria-label="Navigation"]') expect(breadcrumbNav.exists()).toBe(true) // Root label should be the connection display name expect(breadcrumbNav.text()).toContain('Test WebDAV') }) }) // ── Test 4: Warning freshness — cached rows visible ─────────────────────────── describe('warning_freshness_renders_cached_rows', () => { it('renders items and does not clear them on warning freshness response', async () => { api.getCloudFoldersByConnectionId.mockResolvedValue({ items: [ROOT_FOLDER_A, ROOT_FILE_A], capabilities: null, freshness: WARNING_FRESHNESS, }) const wrapper = mount(CloudFolderView, { global: { stubs: leafStubs }, }) await flushPromises() const html = wrapper.html() // Items from the API response must still render even with warning state expect(html).toContain('Alpha') expect(html).toContain('report.pdf') // setBrowseState must have been called with warning, not fresh const warningCall = mockSetBrowseState.mock.calls.find(c => c[0].freshness === 'warning') expect(warningCall).toBeDefined() // Must not have promoted to fresh const freshCall = mockSetBrowseState.mock.calls.find(c => c[0].freshness === 'fresh') expect(freshCall).toBeUndefined() }) }) // ── Test 5: Fresh freshness — no warning shown ──────────────────────────────── describe('fresh_freshness_no_warning', () => { it('renders items normally with fresh server state', async () => { api.getCloudFoldersByConnectionId.mockResolvedValue({ items: [ROOT_FOLDER_A, ROOT_FILE_A, ROOT_FILE_B], capabilities: null, freshness: FRESH_FRESHNESS, }) const wrapper = mount(CloudFolderView, { global: { stubs: leafStubs }, }) await flushPromises() const html = wrapper.html() expect(html).toContain('Alpha') expect(html).toContain('report.pdf') expect(html).toContain('notes.txt') // setBrowseState is called with fresh (from server state) const freshCall = mockSetBrowseState.mock.calls.find(c => c[0].freshness === 'fresh') expect(freshCall).toBeDefined() // No warning freshness set const warningCall = mockSetBrowseState.mock.calls.find(c => c[0].freshness === 'warning') expect(warningCall).toBeUndefined() }) }) // ── Test 6: No v-html on filenames (XSS prevention) ───────────────────────── describe('filenames_render_as_text_not_html', () => { it('XSS payload in filename renders as escaped text, not injected markup', async () => { const XSS_ITEM = { id: 'row-xss', provider_item_id: 'dav/xss.html', name: '.pdf', kind: 'file', parent_ref: null, content_type: 'text/html', size: 100, modified_at: null, etag: null, capabilities: {}, } api.getCloudFoldersByConnectionId.mockResolvedValue({ items: [XSS_ITEM], capabilities: null, freshness: FRESH_FRESHNESS, }) const wrapper = mount(CloudFolderView, { global: { stubs: leafStubs }, }) await flushPromises() // The XSS payload must NOT appear as real HTML elements expect(wrapper.find('img[onerror]').exists()).toBe(false) // Vue auto-escaping: < > are escaped in text nodes const html = wrapper.html() expect(html).not.toContain('') }) })