From 6d294ea7e6a6af76cd2d25e83e36341c0800898a Mon Sep 17 00:00:00 2001 From: curo1305 Date: Mon, 22 Jun 2026 09:41:36 +0200 Subject: [PATCH] test(12.1): add rendered-flow integration tests and security gate evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CloudFolderRenderedFlow.test.js: 7 tests mounting real CloudFolderView with real StorageBrowser/BreadcrumbBar (only API and router stubbed) — mixed root render, opaque-ref folder navigation, breadcrumb lineage, warning freshness cached rows, fresh freshness, XSS text escaping - Update 12.1-VALIDATION.md with Task 3 security gate results: bandit 0 HIGH, npm audit 0 critical, no Phase-12.1 gitleaks findings, 595 backend / 376 frontend tests pass, build clean --- .../12.1-VALIDATION.md | 24 +- .../__tests__/CloudFolderRenderedFlow.test.js | 414 ++++++++++++++++++ 2 files changed, 437 insertions(+), 1 deletion(-) create mode 100644 frontend/src/views/__tests__/CloudFolderRenderedFlow.test.js diff --git a/.planning/phases/12.1-fix-nextcloud-root-listing-and-sync-visibility/12.1-VALIDATION.md b/.planning/phases/12.1-fix-nextcloud-root-listing-and-sync-visibility/12.1-VALIDATION.md index db5260b..179beb7 100644 --- a/.planning/phases/12.1-fix-nextcloud-root-listing-and-sync-visibility/12.1-VALIDATION.md +++ b/.planning/phases/12.1-fix-nextcloud-root-listing-and-sync-visibility/12.1-VALIDATION.md @@ -1,6 +1,6 @@ # Phase 12.1: Fix Nextcloud Root Listing and Sync Visibility — Validation Matrix -**Status:** Plan 04 Task 2 complete. Fixture owner-confirmed; exact-acceptance test enabled. +**Status:** Plan 04 Task 3 complete. All regression, security, and rendered-flow gates pass. **Updated:** 2026-06-22 --- @@ -142,3 +142,25 @@ exact kind equality against the confirmed fixture. - Marker excluded from default runs via `pytest.ini` - Three live tests: adapter metadata, sanitized root diagnostic, connection-ID end-to-end - Task 2 (exact-name acceptance) deferred to checkpoint — fixture not yet created + +### Plan 04 Task 2 — Fixture Reconciliation + +- Owner confirmed corrected names for 3 previously unmatched items (2026-06-22) +- `backend/tests/fixtures/cloud/nextcloud_expected_root.json` created with `owner_confirmed: true` +- `test_nextcloud_expected_root_manifest` enabled with exact set and kind equality (T-12.1-19) +- Unexpected provider names remain withheld (T-12.1-20) + +### Plan 04 Task 3 — Full Regression, Security, and Rendered-Flow Gates + +| Gate | Command | Result | +|------|---------|--------| +| Bandit HIGH findings | `bandit -r backend/ -q` | 0 HIGH (10 LOW, pre-existing) | +| npm audit high/critical | `npm audit --audit-level=high` | 0 vulnerabilities | +| Git tracked .env files | `git ls-files '.env' '.env.*'` | None (only .env.example) | +| Secret scan new findings | `gitleaks detect --redact` | 3 findings, all pre-existing in old commits (May 2026), none from Phase 12.1 | +| Docker Compose config | `docker compose config --quiet` | Clean | +| Focused cloud tests | `pytest test_cloud_*.py test_webdav_backend.py` | 236 pass | +| Full backend suite | `pytest -v` | 595 pass, 1 pre-existing failure (test_extract_docx, unrelated) | +| Full frontend suite | `npm test` | 376 pass (7 new from CloudFolderRenderedFlow.test.js) | +| Frontend build | `npm run build` | Clean | +| Rendered flow test | `npm test -- --run CloudFolderRenderedFlow.test.js` | 7 pass | diff --git a/frontend/src/views/__tests__/CloudFolderRenderedFlow.test.js b/frontend/src/views/__tests__/CloudFolderRenderedFlow.test.js new file mode 100644 index 0000000..4a58cf9 --- /dev/null +++ b/frontend/src/views/__tests__/CloudFolderRenderedFlow.test.js @@ -0,0 +1,414 @@ +/** + * 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('') + }) +})