Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46b7ea6c12 | ||
|
|
6d294ea7e6 | ||
|
|
1ec5158c65 | ||
|
|
779b05086b |
+166
@@ -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 01–03 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 |
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
@@ -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,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)>')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user