test(12.1): add rendered-flow integration tests and security gate evidence

- 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
This commit is contained in:
curo1305
2026-06-22 09:41:36 +02:00
parent 1ec5158c65
commit 6d294ea7e6
2 changed files with 437 additions and 1 deletions
@@ -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 |
@@ -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: '<span data-test-icon />' },
SearchBar: { template: '<div data-test-search />' },
SortControls: { template: '<div data-test-sort />' },
DropZone: {
template: '<div data-test="drop-zone"><slot /></div>',
props: ['disabled'],
emits: ['drop'],
},
UploadProgress: { template: '<div data-test-upload-progress />' },
TopicBadge: { template: '<span data-test-topic />' },
EmptyState: { template: '<div data-test="empty-state"><slot /></div>' },
}
// ── 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: '<img src=x onerror=alert(1)>.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('<img src=x onerror=alert(1)>')
})
})