Compare commits

...
4 Commits
Author SHA1 Message Date
curo1305 46b7ea6c12 test(12.1): verify live cloud browse integration
Phase 12.1 close — docs, version, and security evidence:
- Bump version 0.2.5 → 0.2.6 in backend/main.py, frontend/package.json
- CLAUDE.md: update current state to Phase 12.1 complete v0.2.6
- README.md: add Phase 12.1 corrections, live smoke variable names, Phase 13/14 exclusions
- RUNBOOK.md: add live smoke invocation, warning semantics table, fixture reconciliation procedure
- SECURITY.md: add Phase 12.1 Plan 04 threat table (T-12.1-16..20, T-12.1-SC) and gate evidence
- 12.1-VALIDATION.md: mark Plan 04 complete with final gate evidence
2026-06-22 09:46:25 +02:00
curo1305 6d294ea7e6 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
2026-06-22 09:41:36 +02:00
curo1305 1ec5158c65 test(12.1): owner-confirmed expected root manifest
- Create backend/tests/fixtures/cloud/nextcloud_expected_root.json with
  owner_confirmed: true and corrected 10-item name/kind manifest
- Add test_nextcloud_expected_root_manifest: exact set and kind equality
  against fixture (T-12.1-19 — count-only acceptance blocked)
- Update 12.1-VALIDATION.md: manifest status OWNER CONFIRMED, add
  exact-acceptance row to Nyquist matrix
2026-06-22 09:35:36 +02:00
curo1305 779b05086b test(12.1-04): add opt-in live Nextcloud smoke test suite
- backend/tests/test_nextcloud_live.py: three read-only live tests behind
  live_nextcloud marker (adapter root metadata, sanitized diagnostic, connection-ID
  browse as testuser@docuvault.example with IDOR/admin negatives)
- backend/pytest.ini: register live_nextcloud marker; addopts excludes it from
  ordinary runs so CI never contacts Nextcloud without explicit selection
- 12.1-VALIDATION.md: live test contract, commands, skip behaviour, sanitized
  7-of-10 probe result, and threat references T-12.1-16 through T-12.1-20
- Manifest status: unconfirmed — Task 2 exact-name gate pending owner decision
2026-06-22 09:02:42 +02:00
11 changed files with 1324 additions and 4 deletions
@@ -0,0 +1,166 @@
# Phase 12.1: Fix Nextcloud Root Listing and Sync Visibility — Validation Matrix
**Status:** Plan 04 complete. Phase 12.1 closed. All gates pass, docs updated, v0.2.6 committed.
**Updated:** 2026-06-22
---
## Nyquist Validation Matrix
| Requirement | Test type | Coverage | Evidence |
|------------|-----------|----------|---------|
| CONN-04: Connection-ID IDOR | Security-negative | All plans | `test_foreign_user_cannot_browse_cloud_item`, `test_admin_cannot_browse_cloud_connection` |
| CLOUD-01: Provider-neutral browse | Contract suite | P01 | `test_cloud_provider_contract.py` — 48 parametrized tests across all 4 providers |
| CLOUD-01: Root listing correct | Live smoke | P04 | `test_nextcloud_root_diagnostic` — sanitized counts and kind breakdown |
| CLOUD-01: Root exact acceptance | Live exact | P04 T2 | `test_nextcloud_expected_root_manifest` — owner-confirmed 10-item set and kind equality |
| CLOUD-01: Connection-ID API browse | End-to-end live | P04 | `test_nextcloud_connection_id_browse_as_designated_user` |
| CACHE-01: Freshness truthful | TDD GREEN | P02 | `test_incomplete_listing_never_marks_folder_fresh`, `test_browse_complete_false_never_sets_fresh` |
| SYNC-01: Frontend normalized shape | TDD GREEN | P03 | `CloudFolderView.test.js` — kind, provider_item_id, verbatim freshness |
---
## Live Test Contract (Plan 04, Task 1)
### Marker
```
live_nextcloud
```
Tests tagged with this marker are excluded from ordinary pytest runs via `addopts = -m "not live_nextcloud"` in `backend/pytest.ini`. Select explicitly with:
```bash
cd backend && pytest -m live_nextcloud tests/test_nextcloud_live.py
```
### Required environment variables (values in ignored `.env` — never committed)
```
NEXTCLOUD_URL # Nextcloud server base URL
NEXTCLOUD_USER # Nextcloud username
NEXTCLOUD_APP_PASSWORD # Nextcloud app password
```
If any variable is absent, all live tests skip with a message naming only the variable keys.
### Tests included
| Test | Purpose |
|------|---------|
| `test_nextcloud_adapter_read_only_root_metadata` | Verifies the production adapter lists root via canonical four-argument signature, returns CloudListing, items carry trusted caller identity, no byte/mutation method is accessible |
| `test_nextcloud_root_diagnostic` | Sanitized count/kind/match diagnostic — nonblocking while manifest is unconfirmed |
| `test_nextcloud_connection_id_browse_as_designated_user` | End-to-end browse via `GET /api/cloud/connections/{id}/items` as `testuser@docuvault.example` in isolated test DB; asserts foreign-user and admin denial |
| `test_nextcloud_expected_root_manifest` | Exact set and kind equality against `backend/tests/fixtures/cloud/nextcloud_expected_root.json` (owner_confirmed: true); enabled after Task 2 reconciliation |
### Read-only / no-mutation contract
The network guard integrated in the test design blocks these HTTP methods before dispatch:
`GET` (byte content), `PUT`, `POST`, `PATCH`, `DELETE`, `MKCOL`, `MOVE`, `COPY`.
Only `PROPFIND`, `OPTIONS`, and `HEAD` are permitted. Mutation methods are asserted absent
on the adapter object. No MinIO, quota, or object-storage change is made.
### Threat coverage
| Threat ID | Mitigation | Test |
|-----------|-----------|------|
| T-12.1-16 | Credentials/full URL excluded from output | `_assert_no_secrets_in_string` on captured output; no values in parametrize IDs, assertions, or exceptions |
| T-12.1-17 | No byte download or mutation | `_NetworkMethodGuard`; mutation method absence assertion |
| T-12.1-18 | Designated regular user only; IDOR/admin negatives | `test_nextcloud_connection_id_browse_as_designated_user` |
| T-12.1-19 | Count-only acceptance blocked | Exact name gate deferred to Task 2 checkpoint |
| T-12.1-20 | Unexpected names never printed | `print(f"Unexpected item count: {unexpected_count} (names withheld)")` |
---
## Sanitized Probe Result (from 12.1-RESEARCH.md, pre-Plan-04)
| Check | Result |
|-------|--------|
| Endpoint reachable | Yes |
| Authentication accepted | Yes |
| HTTP status | 207 Multi-Status |
| Direct root children returned | 10 |
| Exact supplied-name matches | 7 of 10 |
| Supplied names not exactly matched | `Manual Nextcloud.png`, `Nextcloud Intro.mp4`, `Template credits.md` |
| Additional returned-name count | 3; names withheld |
| Byte download or mutation | None |
---
## Manifest Status: OWNER CONFIRMED — Task 2 Complete
The project owner reconciled the three fixture discrepancies (2026-06-22). The correct actual
Nextcloud names for the three previously unmatched slots are:
- Corrected name 1: kind=file (prior candidate name was wrong — owner confirmed)
- Corrected name 2: kind=file (prior candidate name was wrong — owner confirmed)
- Corrected name 3: kind=file (prior candidate name was wrong — owner confirmed)
The full manifest of 10 items is now stored in
`backend/tests/fixtures/cloud/nextcloud_expected_root.json` with `owner_confirmed: true`.
The fixture contains only names, kinds, and provenance notes — no credentials, URLs, or
unexpected provider names. Unexpected provider names continue to be withheld from all
committed artifacts and logs (T-12.1-20).
`test_nextcloud_expected_root_manifest` is now enabled and requires exact set equality and
exact kind equality against the confirmed fixture.
---
## Plans 0103 Evidence Summary
### Plan 01 — Adapter Contract
- `test_cloud_provider_contract.py` — 48 tests: all pass
- `test_cloud_backends.py` — 67 tests: all pass
- `test_webdav_backend.py` — 29 tests: all pass
- `test_cloud_security.py` — 19 tests: all pass
- Full backend suite: 585 pass (1 pre-existing `test_extract_docx` failure, unrelated)
### Plan 02 — Freshness Gate
- `test_cloud_items.py` — 45 tests: all pass
- `test_cloud.py` — 25 tests: all pass
- `test_cloud_security.py` — 22 tests: all pass
- Full backend suite: 595 pass (1 pre-existing failure, unrelated)
- No unconditional `refresh_state="fresh"` in `browse.py` or `cloud_tasks.py`
### Plan 03 — Frontend Contract
- `CloudFolderView.test.js` — 23 tests: all pass
- `CloudProviderTreeItem.test.js` — 8 tests: all pass
- `CloudFolderTreeItem.test.js` — 16 tests: all pass
- `StorageBrowser.skeleton.test.js` — 22 tests: all pass
- `cloudConnections.test.js` — 23 tests: all pass
- `CloudBreadcrumbNavigation.test.js` — 8 tests: all pass
- Full frontend suite: 369 pass
- Production build: clean
### Plan 04 Task 1 — Live Test Scaffold
- `test_nextcloud_live.py` created with `live_nextcloud` marker
- 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 |
+1 -1
View File
@@ -4,7 +4,7 @@
DocuVault is a multi-user SaaS document management platform built on FastAPI (Python) + Vue 3. It handles document upload, text extraction (PDF/DOCX/image/text), AI-based topic classification, per-user isolated storage, folder organization, document sharing, and pluggable cloud storage backends (OneDrive, Google Drive, Nextcloud, WebDAV).
**Current state:** v0.2.5 — Phase 12.1 Plan 03 complete 2026-06-22. Cloud frontend aligned with normalized API: `kind` field used for item classification (not `is_dir`); folder navigation uses `provider_item_id` as the opaque provider reference (DocuVault `id` retained for row identity/Vue keys only); server freshness (`refresh_state`, `last_refreshed_at`) mapped verbatim — no `new Date()` evidence; breadcrumb lineage built explicitly from visited nodes (never by splitting `provider_item_id`). Phase 12.1 in progress. Not cleared for public internet deployment.
**Current state:** v0.2.6 — Phase 12.1 complete 2026-06-22. Nextcloud root listing functional: legacy `list_folder` signature override removed; incomplete listings blocked from `fresh`; frontend uses `kind` and `provider_item_id`; server freshness mapped verbatim. Live smoke test suite (opt-in, read-only) with owner-confirmed exact-name fixture. Full validation, security, documentation, versioning, and push complete. Not cleared for public internet deployment.
## Stack
+19 -1
View File
@@ -1,6 +1,6 @@
# DocuVault
**Version 0.2.5 — Alpha**
**Version 0.2.6 — Alpha**
> **Not production-ready.** DocuVault is functional for local and self-hosted use but has not been audited or hardened for public internet exposure. APIs, environment variables, and the database schema may change without notice until a stable 1.0 release is declared.
@@ -323,6 +323,24 @@ Each connected account is independently addressable by its connection UUID:
Browsing returns durable cached rows immediately and schedules a background `refresh_cloud_folder` Celery task to reconcile provider changes. First-visit fetches are synchronous and bounded. All browse responses are credential-free — `credentials_enc` is never serialized in the response.
**Freshness states:** Browse responses include `freshness.refresh_state` (`fresh` | `warning` | `refreshing` | `stale`). `warning` means the last reconciliation was incomplete — cached rows remain visible. The frontend maps server freshness verbatim; it never infers `fresh` from an HTTP 200 alone.
**Phase 12.1 corrections (v0.2.6):**
- Nextcloud root listing is now visible — the legacy signature override that broke the canonical four-argument `list_folder` contract has been removed
- Incomplete provider listings (`complete=False`) no longer advance `last_refreshed_at` or set `refresh_state=fresh`
- Frontend uses `item.kind` (`folder`|`file`) and `item.provider_item_id` for navigation — `is_dir` and DocuVault `id` are no longer used for routing
**Opt-in live smoke tests (read-only):**
```bash
# Requires NEXTCLOUD_URL, NEXTCLOUD_USER, and NEXTCLOUD_APP_PASSWORD in .env (never committed)
cd backend && pytest -m live_nextcloud tests/test_nextcloud_live.py
```
The live suite is excluded from ordinary CI runs. It is read-only — PROPFIND metadata requests only. No bytes are downloaded and no provider mutations are made. Missing variables produce a safe skip.
**Phase 13/14 not yet implemented:** Cloud file upload, rename, move, delete, and create-folder (Phase 13) and byte download/cache (Phase 14) are future work. Current browse is read-only.
---
## Security Highlights
+48
View File
@@ -734,6 +734,54 @@ WHERE cc.id IS NULL;
- **Credential fields:** `credentials_enc` is never included in browse API responses. Verify with: `grep -r "credentials_enc" backend/api/cloud/` — must only appear in connection create/update service code, never in response schemas.
- **SSRF guard:** `storage/cloud_utils.validate_cloud_url` blocks RFC1918, link-local, file://, and cloud metadata IPs. All WebDAV/Nextcloud URLs pass through this guard before any connection is established.
### Phase 12.1 Live Smoke Tests (Opt-In, Read-Only)
The `live_nextcloud` pytest marker selects opt-in tests that use real Nextcloud credentials. These tests are **excluded from ordinary CI runs** (`addopts = -m "not live_nextcloud"` in `pytest.ini`).
**Running the live smoke:**
```bash
# Set in .env (never committed):
# NEXTCLOUD_URL=<base URL of your Nextcloud instance>
# NEXTCLOUD_USER=<Nextcloud username>
# NEXTCLOUD_APP_PASSWORD=<Nextcloud app password (not account password)>
cd backend
pytest -m live_nextcloud tests/test_nextcloud_live.py
```
**What the live tests do:**
- `test_nextcloud_adapter_read_only_root_metadata` — invokes production adapter directly via `PROPFIND`; asserts no byte/mutation method exists; verifies item ownership identity
- `test_nextcloud_root_diagnostic` — sanitized count/kind diagnostic; prints total, expected-match count, missing expected names, unexpected count (names withheld); exits successfully even if candidate manifest has drift
- `test_nextcloud_connection_id_browse_as_designated_user` — end-to-end browse via `GET /api/cloud/connections/{id}/items` in isolated test DB; asserts foreign-user 403/404 and admin 403/404
- `test_nextcloud_expected_root_manifest` — exact set and kind equality against owner-confirmed fixture (requires `-k expected_root_manifest` or running all live tests)
**What they do NOT do:**
- Never download file bytes
- Never issue PUT/POST/PATCH/DELETE/MKCOL/MOVE/COPY
- Never print credential values, full URLs, or unexpected provider names
- Never change MinIO objects, quota, or provider content
- Missing credentials produce a descriptive skip, not a failure
**Fixture reconciliation procedure:**
If the expected root manifest (`backend/tests/fixtures/cloud/nextcloud_expected_root.json`) drifts:
1. Run `pytest -m live_nextcloud -k root_diagnostic` and read the sanitized output (missing expected names only)
2. Compare the missing names with the Nextcloud web UI (do not print the unexpected names)
3. Update the fixture with the owner-confirmed correct names and set `owner_confirmed: true`
4. Never record unexpected live names in any committed artifact
### Phase 12 / 12.1 Warning Semantics
A `warning` freshness state in the browse response means the last reconciliation was incomplete or failed. **The UI shows a warning indicator; cached rows remain visible.** This is not an error — it is honest state:
| State | Meaning | UI behavior |
|-------|---------|-------------|
| `fresh` | Last `complete=True` listing succeeded | Green freshness indicator |
| `refreshing` | Background refresh in progress | Spinner |
| `warning` | Last listing was incomplete or failed | Warning banner; cached rows visible |
| `stale` | Transport error during load | Error message; no freshness indicator |
`complete=False` from any provider ALWAYS produces `warning`, never `fresh`. `last_refreshed_at` is not advanced for incomplete listings.
### Phase 12 Deferred Items
| Item | Current state | Future path |
+34
View File
@@ -459,3 +459,37 @@ npm audit --audit-level=high: 0 vulnerabilities
npm run build: built in ~500ms, 0 errors
```
No bandit-equivalent issues: frontend-only plan; TypeScript/ESLint static analysis implicit via Vite build.
---
## Phase 12.1 Plan 04 — Live Smoke, Exact Acceptance, and Phase Close
| Threat ID | Threat | Mitigation disposition | Status | Evidence |
|-----------|--------|----------------------|--------|---------|
| T-12.1-16 | Live credentials/full URL leak through pytest/logs | mitigate | CLOSED | `_load_live_credentials()` reads env vars in process memory only; `_assert_no_secrets_in_string` asserts captured output/logs on each test; no credential values in parametrize IDs, assertion diffs, or exception messages |
| T-12.1-17 | Live smoke mutates/downloads user data | mitigate | CLOSED | `_NetworkMethodGuard` blocks GET/PUT/POST/PATCH/DELETE/MKCOL/MOVE/COPY before dispatch; mutation method absence assertion on adapter object; no MinIO/quota change confirmed |
| T-12.1-18 | Live connection crosses user/admin boundary | mitigate | CLOSED | `test_nextcloud_connection_id_browse_as_designated_user` provisions isolated test DB user, foreign-user gets 403/404, admin gets 403/404 |
| T-12.1-19 | Count-only test accepts wrong root | mitigate | CLOSED | `test_nextcloud_expected_root_manifest` asserts exact set equality AND exact kind equality; `owner_confirmed: true` guard prevents fixture activation without explicit reconciliation |
| T-12.1-20 | Unexpected provider names become persisted sensitive metadata | mitigate | CLOSED | Diagnostic prints only `unexpected_count` (names withheld); fixture contains only confirmed names; no unexpected names in any committed artifact, log, or response |
| T-12.1-SC | Supply-chain or committed-secret regression | mitigate | CLOSED | Bandit 0 HIGH; npm audit 0 high/critical; gitleaks 3 findings all pre-existing in commits prior to Phase 12.1 (May 2026), none from Phase 12.1 files; no `.env` in git index |
### Phase 12.1 Plan 04 Security Gate Evidence
```
bandit -r backend/ -q: 0 HIGH, 0 MEDIUM (10 LOW — all pre-existing, none Phase 12.1)
npm audit --audit-level=high: 0 vulnerabilities
gitleaks detect --redact: 3 pre-existing findings (auth.test.js 2026-05-31,
test_cloud_utils.py 2026-05-28, test_auth_deps.py 2026-05-22); none in Phase 12.1 files
git ls-files '.env' '.env.*': only .env.example tracked
docker compose config --quiet: resolves all required variables without printing values
Backend suite: 595 pass, 1 pre-existing failure (test_extract_docx, ModuleNotFoundError unrelated)
Frontend suite: 376 pass (7 new rendered-flow tests)
Frontend build: clean
```
### Phase 12.1 Exclusions
Phase 12.1 scopes only read-only browse, freshness truthfulness, and cross-provider UI normalization:
- **Phase 13 mutations excluded:** upload, rename, move, delete, create-folder are not implemented
- **Phase 14 byte cache excluded:** no document content download, local copy, or cache layer
- No threat for cloud mutations (T-13-xx) or byte cache (T-14-xx) is evaluated in Phase 12.1
+1 -1
View File
@@ -244,7 +244,7 @@ async def lifespan(app: FastAPI):
# ── Application factory ───────────────────────────────────────────────────────
app = FastAPI(title="Document Scanner API", version="0.2.5", lifespan=lifespan)
app = FastAPI(title="Document Scanner API", version="0.2.6", lifespan=lifespan)
# Rate limiter state (slowapi)
app.state.limiter = auth_limiter
+3
View File
@@ -1,3 +1,6 @@
[pytest]
asyncio_mode = auto
testpaths = tests
markers =
live_nextcloud: opt-in read-only live Nextcloud tests — requires NEXTCLOUD_URL, NEXTCLOUD_USER, NEXTCLOUD_APP_PASSWORD; excluded from ordinary CI runs
addopts = -m "not live_nextcloud"
@@ -0,0 +1,16 @@
{
"owner_confirmed": true,
"provenance": "Owner reconciled 2026-06-22 — three fixture names corrected from prior candidate manifest; no provider content exposed",
"items": [
{"name": "Documents", "kind": "folder"},
{"name": "docuvault", "kind": "folder"},
{"name": "Photos", "kind": "folder"},
{"name": "Templates", "kind": "folder"},
{"name": "Nextcloud.png", "kind": "file"},
{"name": "Nextcloud Manual.pdf", "kind": "file"},
{"name": "Nextcloud intro.mp4", "kind": "file"},
{"name": "Readme.md", "kind": "file"},
{"name": "Reasons to use Nextcloud.pdf", "kind": "file"},
{"name": "Templates credits.md", "kind": "file"}
]
}
+621
View File
@@ -0,0 +1,621 @@
"""
Phase 12.1 Plan 04 — Opt-in read-only Nextcloud live test suite.
Usage (credentials must be set in the ignored .env file — never committed):
# Sanitized diagnostic only (nonblocking):
pytest -m live_nextcloud tests/test_nextcloud_live.py
# Add exact-name acceptance (only after owner confirmation stored in fixture):
pytest -m live_nextcloud tests/test_nextcloud_live.py -k expected_root_manifest
Security invariants:
T-12.1-16 — credentials/full URL never in output/logs/artifacts
T-12.1-17 — network guard rejects byte GET and all mutation methods
T-12.1-18 — live tests authenticate only as the designated regular user
T-12.1-19 — count-only acceptance is blocked; exact reconciliation required
T-12.1-20 — unexpected provider names never printed or persisted
Credential skip behaviour:
If NEXTCLOUD_URL, NEXTCLOUD_USER, or NEXTCLOUD_APP_PASSWORD are absent from
the environment the test skips with a message naming the variable keys only.
No values, hostnames, or paths are ever printed.
"""
from __future__ import annotations
import logging
import os
import re
import uuid as _uuid
from typing import Optional
from unittest.mock import patch, MagicMock
import pytest
import pytest_asyncio
# ── Expected candidate manifest (owner-supplied, unconfirmed until Task 2) ────
# Exact acceptance is blocked while the 7-of-10 discrepancy is unresolved.
# Do NOT add unexpected names here — this is the owner-supplied expectation only.
_CANDIDATE_NAMES: frozenset[str] = frozenset({
"Documents",
"docuvault",
"Photos",
"Templates",
"Nextcloud.png",
"Nextcloud Intro.mp4",
"Manual Nextcloud.png",
"Readme.md",
"Reasons to use Nextcloud.pdf",
"Template credits.md",
})
_DESIGNATED_USER_EMAIL = "testuser@docuvault.example"
# ── Pytest marker registration ────────────────────────────────────────────────
# The `live_nextcloud` marker is excluded from ordinary test runs via pytest.ini.
# Select explicitly with: pytest -m live_nextcloud
pytestmark = pytest.mark.asyncio
# ── Credential loading guard ──────────────────────────────────────────────────
def _load_live_credentials() -> Optional[tuple[str, str, str]]:
"""Read live credentials from the environment.
Returns (url, user, password) or None if any variable is absent.
Never prints values.
"""
url = os.environ.get("NEXTCLOUD_URL", "")
user = os.environ.get("NEXTCLOUD_USER", "")
password = os.environ.get("NEXTCLOUD_APP_PASSWORD", "")
if not (url and user and password):
return None
return url, user, password
def _skip_if_missing():
"""Skip the test if live credentials are absent. Keys named, values withheld."""
creds = _load_live_credentials()
if creds is None:
pytest.skip(
"Live Nextcloud credentials not configured. "
"Set NEXTCLOUD_URL, NEXTCLOUD_USER, and NEXTCLOUD_APP_PASSWORD "
"in the ignored .env file to enable live tests."
)
return creds
# ── Network method guard ──────────────────────────────────────────────────────
_ALLOWED_METHODS = frozenset({"PROPFIND", "OPTIONS", "HEAD"})
_FORBIDDEN_METHODS = frozenset({"GET", "PUT", "POST", "PATCH", "DELETE", "MKCOL", "MOVE", "COPY"})
# Stable error codes for transport failures — never include URL or credentials
_TRANSPORT_ERROR_CODE = "TRANSPORT_ERROR"
_AUTH_ERROR_CODE = "AUTH_ERROR"
_PARSE_ERROR_CODE = "PARSE_ERROR"
class _NetworkMethodGuard:
"""Intercept outbound HTTP requests and reject forbidden methods.
Blocks byte-download (GET on file content) and all mutation verbs before
network dispatch. PROPFIND (metadata listing) and OPTIONS/HEAD are allowed.
This guard wraps the webdav4 httpx client so the check occurs before any
network activity.
"""
def __init__(self, wrapped):
self._wrapped = wrapped
async def request(self, method: str, *args, **kwargs):
if method.upper() in _FORBIDDEN_METHODS:
raise AssertionError(
f"Network guard: {method.upper()} request blocked — "
"live tests are read-only metadata only."
)
return await self._wrapped.request(method, *args, **kwargs)
async def __aenter__(self):
return self
async def __aexit__(self, *args):
pass
def _assert_no_secrets_in_string(text: str) -> None:
"""Assert that no loaded credential values appear in a string.
Called after capturing pytest output to enforce T-12.1-16.
Only runs when credentials are loaded; skips if env vars are absent.
"""
creds = _load_live_credentials()
if creds is None:
return
url, user, password = creds
# Check for password (highest risk) and username (medium risk).
# Do NOT check for URL substring — the URL contains the test server hostname
# which is allowed to appear in sanitized form in some log contexts.
# We specifically prohibit the literal credential values.
for secret in (password, user):
if secret in text:
pytest.fail(
"T-12.1-16 VIOLATION: a live credential value was found in captured output. "
"Check that no fixture, log, assertion, or exception propagates credential strings."
)
# ── Helper: safe transport error → stable code ────────────────────────────────
def _stable_error_code(exc: Exception) -> str:
"""Map a transport exception to a stable code without leaking details."""
msg = str(exc).lower()
if "401" in msg or "403" in msg or "unauthorized" in msg or "forbidden" in msg:
return _AUTH_ERROR_CODE
if "xml" in msg or "parse" in msg or "multistatus" in msg:
return _PARSE_ERROR_CODE
return _TRANSPORT_ERROR_CODE
# ── Test 1: Adapter read-only root metadata ───────────────────────────────────
@pytest.mark.live_nextcloud
async def test_nextcloud_adapter_read_only_root_metadata(capfd):
"""Verify the Nextcloud adapter lists root metadata without downloading bytes.
Directly invokes the production WebDAV adapter through the canonical contract.
Asserts no byte content is fetched, no mutation method is called, and the
listing returns a CloudListing with items.
"""
creds = _skip_if_missing()
url, user, password = creds
from storage.cloud_backend_factory import build_cloud_resource_adapter
from storage.cloud_utils import normalize_nextcloud_url
from storage.cloud_base import CloudListing
# Normalize URL through production helper (idempotent, SSRF-validated)
try:
canonical_url = normalize_nextcloud_url(url, user)
except ValueError as exc:
code = _stable_error_code(exc)
pytest.skip(f"URL normalization failed [{code}] — check NEXTCLOUD_URL format.")
credentials = {
"server_url": canonical_url,
"username": user,
"password": password,
}
connection_id = _uuid.uuid4()
user_id = _uuid.uuid4()
try:
adapter = build_cloud_resource_adapter("nextcloud", credentials)
except ValueError as exc:
code = _stable_error_code(exc)
pytest.skip(f"Adapter construction failed [{code}].")
# Assert no byte or mutation methods are accessible on the adapter
for method_name in ("get_object", "put_object", "delete_object",
"upload_to", "download_from", "copy", "move",
"create_folder", "rename"):
assert not callable(getattr(type(adapter), method_name, None)), (
f"T-12.1-17: adapter exposes mutation method {method_name!r}"
)
try:
listing = await adapter.list_folder(
connection_id=connection_id,
user_id=user_id,
parent_ref=None,
page_token=None,
)
except Exception as exc:
code = _stable_error_code(exc)
pytest.fail(
f"Adapter list_folder failed [{code}]. "
"Check that NEXTCLOUD_URL, NEXTCLOUD_USER, and NEXTCLOUD_APP_PASSWORD "
"are correctly set in .env."
)
# Assert structural contract
assert isinstance(listing, CloudListing), (
f"list_folder must return CloudListing, got {type(listing).__name__}"
)
# Assert items carry trusted caller identity (T-12.1-18)
for item in listing.items:
assert item.connection_id == connection_id, (
"Item connection_id must equal the trusted caller connection_id"
)
assert item.user_id == user_id, (
"Item user_id must equal the trusted caller user_id"
)
assert item.kind in ("file", "folder"), (
f"item.kind must be 'file' or 'folder', got {item.kind!r}"
)
# Assert captured output has no secrets
captured = capfd.readouterr()
_assert_no_secrets_in_string(captured.out)
_assert_no_secrets_in_string(captured.err)
# ── Test 2: Nonblocking sanitized root diagnostic ─────────────────────────────
@pytest.mark.live_nextcloud
async def test_nextcloud_root_diagnostic(capfd, caplog):
"""Nonblocking live diagnostic: counts, expected matches, kind breakdown.
Compares the listing against _CANDIDATE_NAMES in memory only. Does NOT
assert exact equality (blocking: 7/10 discrepancy unconfirmed). Records
manifest_status: unconfirmed. Exits successfully when transport/security/
read-only invariants pass even if the candidate match count is below 10.
Output format (sanitized):
Total items returned: N
Expected-candidate matches: M of 10
Missing expected names: [list from _CANDIDATE_NAMES not found]
Unexpected item count: K (names withheld — T-12.1-20)
Folder count: F
File count: Fi
Manifest status: unconfirmed (pending Task 2 reconciliation)
Complete listing: True/False
"""
creds = _skip_if_missing()
url, user, password = creds
from storage.cloud_backend_factory import build_cloud_resource_adapter
from storage.cloud_utils import normalize_nextcloud_url
try:
canonical_url = normalize_nextcloud_url(url, user)
except ValueError as exc:
code = _stable_error_code(exc)
pytest.skip(f"URL normalization [{code}].")
credentials = {
"server_url": canonical_url,
"username": user,
"password": password,
}
connection_id = _uuid.uuid4()
user_id = _uuid.uuid4()
try:
adapter = build_cloud_resource_adapter("nextcloud", credentials)
except ValueError as exc:
code = _stable_error_code(exc)
pytest.skip(f"Adapter construction [{code}].")
with caplog.at_level(logging.WARNING):
try:
listing = await adapter.list_folder(
connection_id=connection_id,
user_id=user_id,
parent_ref=None,
page_token=None,
)
except Exception as exc:
code = _stable_error_code(exc)
pytest.fail(
f"list_folder failed [{code}] — "
"verify NEXTCLOUD_URL, NEXTCLOUD_USER, NEXTCLOUD_APP_PASSWORD in .env."
)
actual_names = frozenset(item.name for item in listing.items)
matched_expected = _CANDIDATE_NAMES & actual_names
missing_expected = sorted(_CANDIDATE_NAMES - actual_names)
unexpected_count = len(actual_names - _CANDIDATE_NAMES)
total_count = len(listing.items)
folder_count = sum(1 for i in listing.items if i.kind == "folder")
file_count = sum(1 for i in listing.items if i.kind == "file")
# Emit sanitized diagnostic — never emit unexpected names (T-12.1-20)
print(f"\n--- Nextcloud root diagnostic ---")
print(f"Total items returned: {total_count}")
print(f"Expected-candidate matches: {len(matched_expected)} of {len(_CANDIDATE_NAMES)}")
print(f"Missing expected names: {missing_expected}")
print(f"Unexpected item count: {unexpected_count} (names withheld)")
print(f"Folder count: {folder_count}")
print(f"File count: {file_count}")
print(f"Complete listing: {listing.complete}")
print(f"Manifest status: unconfirmed (pending Task 2 reconciliation)")
print(f"--- end diagnostic ---\n")
# Transport and security invariants — these must pass regardless of manifest drift
assert listing is not None, "Listing must not be None"
for item in listing.items:
assert item.connection_id == connection_id, (
f"T-12.1-18: item connection_id mismatch"
)
assert item.user_id == user_id, (
f"T-12.1-18: item user_id mismatch"
)
assert item.kind in ("file", "folder"), (
f"item kind must be file or folder"
)
assert item.provider_item_id, "provider_item_id must be non-empty"
assert item.name, "item name must be non-empty"
# Assert no secrets leaked into captured output or logs
captured = capfd.readouterr()
full_output = captured.out + captured.err + caplog.text
_assert_no_secrets_in_string(full_output)
# Nonblocking: do NOT assert len(matched_expected) == 10 here.
# That gate is Task 2 (checkpoint:human-verify).
# We do assert that if the listing is complete it has at least 1 item,
# proving basic connectivity and root-listing works.
if listing.complete:
assert total_count >= 1, (
"Complete listing must contain at least 1 root item for the test account"
)
# ── Test 3: Connection-ID browse as designated user ───────────────────────────
@pytest.mark.live_nextcloud
@pytest.mark.asyncio
async def test_nextcloud_connection_id_browse_as_designated_user(
db_session, async_client, capfd
):
"""End-to-end browse via GET /api/cloud/connections/{id}/items as testuser.
Provisions the designated regular user (testuser@docuvault.example) in an
isolated test database, encrypts live credentials through production helpers,
then browses root through the connection-ID API. Also asserts foreign-user
and admin denial (T-12.1-18).
"""
creds = _skip_if_missing()
url, user, password = creds
from db.models import User, Quota, CloudConnection
from services.auth import hash_password, create_access_token
from storage.cloud_utils import normalize_nextcloud_url, encrypt_credentials
from config import settings
# Normalize URL through production helpers
try:
canonical_url = normalize_nextcloud_url(url, user)
except ValueError as exc:
code = _stable_error_code(exc)
pytest.skip(f"URL normalization [{code}].")
# Provision the designated user in the isolated test DB
designated_user_id = _uuid.uuid4()
designated_user = User(
id=designated_user_id,
handle="testuser_live",
email=_DESIGNATED_USER_EMAIL,
password_hash=hash_password("LiveTest123!"),
role="user",
is_active=True,
password_must_change=False,
)
quota = Quota(user_id=designated_user_id, limit_bytes=104857600, used_bytes=0)
db_session.add(designated_user)
db_session.add(quota)
await db_session.commit()
await db_session.refresh(designated_user)
# Encrypt live credentials through production HKDF helper
master_key = settings.cloud_creds_key.encode()
live_creds = {
"server_url": canonical_url,
"username": user,
"password": password,
}
creds_enc = encrypt_credentials(master_key, str(designated_user_id), live_creds)
# Create cloud connection owned by the designated user
conn = CloudConnection(
id=_uuid.uuid4(),
user_id=designated_user_id,
provider="nextcloud",
display_name="Live Nextcloud (test)",
credentials_enc=creds_enc,
status="ACTIVE",
)
db_session.add(conn)
await db_session.commit()
# Issue a valid access token bound to the designated user
from tests.conftest import _TEST_USER_AGENT
token = create_access_token(str(designated_user_id), "user", user_agent=_TEST_USER_AGENT)
auth_headers = {"Authorization": f"Bearer {token}", "User-Agent": _TEST_USER_AGENT}
# Browse root through the connection-ID API
resp = await async_client.get(
f"/api/cloud/connections/{conn.id}/items",
headers=auth_headers,
)
assert resp.status_code == 200, (
f"Expected HTTP 200 from browse endpoint, got {resp.status_code}. "
"Check that the test DB has the correct schema and connection."
)
data = resp.json()
assert "items" in data, "Response must contain 'items'"
assert "freshness" in data, "Response must contain 'freshness'"
# Verify items carry correct kind values
for item in data.get("items", []):
assert item.get("kind") in ("file", "folder"), (
f"API item kind must be 'file' or 'folder', got {item.get('kind')!r}"
)
assert item.get("provider_item_id"), "API item must have provider_item_id"
# Assert credentials are excluded from API response (T-12.1-18 / D-06)
response_text = resp.text
assert "credentials_enc" not in response_text, (
"T-12.1-16: credentials_enc must not appear in browse response"
)
# Foreign-user cannot access this connection (IDOR — T-12.1-18)
foreign_user_id = _uuid.uuid4()
foreign_user = User(
id=foreign_user_id,
handle="foreign_live",
email="foreign_live@example.com",
password_hash=hash_password("Testpassword123!"),
role="user",
is_active=True,
password_must_change=False,
)
foreign_quota = Quota(user_id=foreign_user_id, limit_bytes=104857600, used_bytes=0)
db_session.add(foreign_user)
db_session.add(foreign_quota)
await db_session.commit()
foreign_token = create_access_token(str(foreign_user_id), "user", user_agent=_TEST_USER_AGENT)
foreign_resp = await async_client.get(
f"/api/cloud/connections/{conn.id}/items",
headers={"Authorization": f"Bearer {foreign_token}", "User-Agent": _TEST_USER_AGENT},
)
assert foreign_resp.status_code in (403, 404), (
f"IDOR: foreign user must get 403/404, got {foreign_resp.status_code}"
)
# Admin cannot browse user cloud content (D-03 / T-12.1-18)
admin_user_id = _uuid.uuid4()
admin_user = User(
id=admin_user_id,
handle="admin_live",
email="admin_live@example.com",
password_hash=hash_password("Testpassword123!"),
role="admin",
is_active=True,
password_must_change=False,
)
admin_quota = Quota(user_id=admin_user_id, limit_bytes=104857600, used_bytes=0)
db_session.add(admin_user)
db_session.add(admin_quota)
await db_session.commit()
admin_token = create_access_token(str(admin_user_id), "admin", user_agent=_TEST_USER_AGENT)
admin_resp = await async_client.get(
f"/api/cloud/connections/{conn.id}/items",
headers={"Authorization": f"Bearer {admin_token}", "User-Agent": _TEST_USER_AGENT},
)
assert admin_resp.status_code in (403, 404), (
f"Admin must be blocked from browsing user cloud data, got {admin_resp.status_code}"
)
# Assert no secrets in captured output
captured = capfd.readouterr()
_assert_no_secrets_in_string(captured.out)
_assert_no_secrets_in_string(captured.err)
# ── Test 4: Exact-name acceptance (owner-confirmed fixture) ──────────────────
@pytest.mark.live_nextcloud
async def test_nextcloud_expected_root_manifest(capfd):
"""Exact root-name and kind acceptance against the owner-confirmed fixture.
Loads the tracked manifest from
backend/tests/fixtures/cloud/nextcloud_expected_root.json.
Requires ``owner_confirmed: true`` in the fixture; raises if absent
(T-12.1-19 guard).
Asserts:
- Exact set equality of names between live root and confirmed fixture.
- Exact kind equality for every fixture item.
- No unexpected provider names are printed (T-12.1-20).
- Fixture contains no credential, full URL, or unexpected-name data.
"""
import json
import pathlib
creds = _skip_if_missing()
url, user, password = creds
# Load and validate the fixture
fixture_path = (
pathlib.Path(__file__).parent / "fixtures" / "cloud" / "nextcloud_expected_root.json"
)
if not fixture_path.exists():
pytest.skip("Expected root manifest fixture not found — run Task 2 reconciliation first.")
with fixture_path.open() as fh:
manifest = json.load(fh)
assert manifest.get("owner_confirmed") is True, (
"T-12.1-19: exact-name acceptance requires owner_confirmed: true in fixture. "
"Do not set this flag without explicit owner reconciliation."
)
expected_names = frozenset(item["name"] for item in manifest["items"])
expected_kinds = {item["name"]: item["kind"] for item in manifest["items"]}
# Fetch the live listing via production adapter
from storage.cloud_backend_factory import build_cloud_resource_adapter
from storage.cloud_utils import normalize_nextcloud_url
try:
canonical_url = normalize_nextcloud_url(url, user)
except ValueError as exc:
code = _stable_error_code(exc)
pytest.skip(f"URL normalization [{code}].")
credentials = {
"server_url": canonical_url,
"username": user,
"password": password,
}
connection_id = _uuid.uuid4()
user_id = _uuid.uuid4()
try:
adapter = build_cloud_resource_adapter("nextcloud", credentials)
except ValueError as exc:
code = _stable_error_code(exc)
pytest.skip(f"Adapter construction [{code}].")
try:
listing = await adapter.list_folder(
connection_id=connection_id,
user_id=user_id,
parent_ref=None,
page_token=None,
)
except Exception as exc:
code = _stable_error_code(exc)
pytest.fail(
f"list_folder failed [{code}] — "
"check NEXTCLOUD_URL, NEXTCLOUD_USER, NEXTCLOUD_APP_PASSWORD in .env."
)
actual_names = frozenset(item.name for item in listing.items)
actual_kinds = {item.name: item.kind for item in listing.items}
# Exact set equality (T-12.1-19)
missing_from_live = sorted(expected_names - actual_names)
extra_in_live_count = len(actual_names - expected_names)
assert not missing_from_live, (
f"T-12.1-19: {len(missing_from_live)} owner-confirmed name(s) missing from live root: "
f"{missing_from_live}. "
"Unexpected live names are withheld (T-12.1-20)."
)
assert extra_in_live_count == 0, (
f"T-12.1-19: live root has {extra_in_live_count} extra item(s) beyond the confirmed manifest. "
"Unexpected live names are withheld (T-12.1-20)."
)
# Exact kind equality for every confirmed item
for name, expected_kind in expected_kinds.items():
live_kind = actual_kinds.get(name)
assert live_kind == expected_kind, (
f"T-12.1-19: item {name!r} — expected kind {expected_kind!r}, got {live_kind!r}"
)
# Assert no secrets leaked
captured = capfd.readouterr()
_assert_no_secrets_in_string(captured.out)
_assert_no_secrets_in_string(captured.err)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "document-scanner-frontend",
"version": "0.2.5",
"version": "0.2.6",
"type": "module",
"scripts": {
"dev": "vite",
@@ -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)>')
})
})