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('
')
+ })
+})