---
phase: "12.1"
plan: "01"
subsystem: cloud-storage-backends
status: complete
tags: [nextcloud, webdav, google-drive, onedrive, cloud-resource-adapter, contract-tests, security]
completed: "2026-06-22"
duration_seconds: 823
task_count: 4
file_count: 14

requires: []
provides:
  - "Canonical four-provider CloudResourceAdapter contract suite"
  - "Nextcloud list_folder signature defect eliminated"
  - "normalize_nextcloud_url shared helper"
  - "OneDrive @odata.nextLink cross-origin guard"
affects: [cloud-browse-api, cloud-backend-factory, provider-tests]

tech_stack:
  added: []
  patterns:
    - "TDD RED/GREEN — contract tests written first, production defect confirmed, then implementation fixed"
    - "normalize_nextcloud_url idempotent URL derivation with SSRF passthrough"
    - "OneDrive cross-origin nextLink validation before following"

key_files:
  created:
    - backend/tests/test_cloud_provider_contract.py
    - backend/tests/fixtures/cloud/nextcloud_root.xml
    - backend/tests/fixtures/cloud/webdav_root.xml
    - backend/tests/fixtures/cloud/google_drive_pages.json
    - backend/tests/fixtures/cloud/onedrive_pages.json
  modified:
    - backend/storage/nextcloud_backend.py
    - backend/storage/cloud_utils.py
    - backend/storage/cloud_backend_factory.py
    - backend/storage/onedrive_backend.py
    - backend/tests/test_cloud_backends.py
    - backend/tests/test_webdav_backend.py
    - backend/main.py
    - frontend/package.json
    - CLAUDE.md
    - README.md

decisions:
  - "Remove NextcloudBackend.list_folder override so Nextcloud inherits canonical WebDAVBackend method (direct fix for P0)"
  - "normalize_nextcloud_url lives in cloud_utils.py — called at factory construction, not in every request"
  - "OneDrive nextLink restricted to graph.microsoft.com origin — cross-origin returns complete=False with prior items retained"
  - "asyncio import restored to nextcloud_backend.py (health_check uses asyncio.to_thread)"
---

# Phase 12.1 Plan 01: Restore Cloud Adapter Contract — Summary

**One-liner:** Removed incompatible `NextcloudBackend.list_folder(folder_path="")` override, added `normalize_nextcloud_url()`, cross-origin OneDrive nextLink guard, and parametrized four-provider contract suite.

## What Was Built

### Task 1 — TDD RED: Four-provider contract tests and fixtures

- `backend/tests/test_cloud_provider_contract.py` — 48 parametrized tests across Nextcloud, WebDAV, Google Drive, OneDrive covering:
  - Canonical signature verification (`connection_id, user_id, parent_ref=None, page_token=None`)
  - Return type is always `CloudListing` (never `list[dict]`)
  - Trusted caller identity (`connection_id`, `user_id`) is never overrideable by provider response
  - `provider_item_id` is set; `kind` is exactly "file" or "folder"
  - `parent_ref` is propagated to each resource
  - Absent optional metadata normalizes to `None`
  - All pages consumed before `complete=True`; page failure → `complete=False`
  - Forbidden-operation spies for `get_object`, `put_object`, `delete_object`, etc.
- Synthetic credential-free fixtures: `nextcloud_root.xml`, `webdav_root.xml`, `google_drive_pages.json`, `onedrive_pages.json`
- 9 Nextcloud-specific tests confirmed RED (TypeError on canonical invocation) as expected

### Task 2 — GREEN: Repair Nextcloud/WebDAV listing

- Removed `async def list_folder(self, folder_path: str = "") -> list[dict]` from `NextcloudBackend` — root cause of P0 defect
- `NextcloudBackend` now inherits `WebDAVBackend.list_folder` via standard Python inheritance
- Added `normalize_nextcloud_url(base_url, username)` to `cloud_utils.py`:
  - Accepts bare origin, origin with subpath, already-canonical URL
  - Username percent-encoded as single path segment
  - HTTPS-only; rejects userinfo/query/fragment
  - Idempotent — no double-append of WebDAV suffix
  - SSRF-validated via existing `validate_cloud_url()` before return
- `cloud_backend_factory.py` calls `normalize_nextcloud_url` before `NextcloudBackend` construction; `ValueError` from DNS failures is caught gracefully
- Added `TestNextcloudBackendNoListFolderOverride` and `TestNextcloudUrlNormalization` to `test_webdav_backend.py`
- Restored `asyncio` import (was inadvertently removed with the legacy `list_folder`; `health_check` still uses `asyncio.to_thread`)

### Task 3 — Drive/OneDrive contract completion

- Added `_GRAPH_HOST` constant to `onedrive_backend.py`; `@odata.nextLink` hostname validated against `graph.microsoft.com` before following; cross-origin → `complete=False` with prior items retained
- Added `TestOneDriveCrossOriginNextLink`: cross-origin rejection, same-origin follows, page failure retains prior items
- Added `TestGoogleDriveAuthFailureControl`: 401 → `complete=False`, page1+page2 error retains page1 items

### Task 4 — Documentation and version bump

- Version bumped to `0.2.3` in `backend/main.py` and `frontend/package.json`
- `CLAUDE.md` updated: current state, shared module map, Nextcloud no-override rule
- `README.md` updated: version, Nextcloud URL normalization note, OneDrive nextLink origin guard

## Deviations from Plan

### Auto-fixed Issues

**1. [Rule 1 - Bug] Missing asyncio import in NextcloudBackend after legacy method removal**
- **Found during:** Task 4 (full test suite run)
- **Issue:** Removing the legacy `list_folder` method also removed the only active use of `asyncio` in the file, but the `health_check` method still calls `asyncio.to_thread`. This caused `test_nextcloud_connect_persists` and `test_same_provider_connections_are_independent` to fail.
- **Fix:** Re-added `import asyncio` to `nextcloud_backend.py`
- **Files modified:** `backend/storage/nextcloud_backend.py`
- **Commit:** 6212873

**2. [Rule 2 - Missing security] OneDrive @odata.nextLink cross-origin validation**
- **Found during:** Task 3 (plan specifies "reject cross-origin/untrusted continuation URLs")
- **Issue:** `onedrive_backend.py` followed `@odata.nextLink` without verifying the target stays on `graph.microsoft.com`; an SSRF-adjacent CSRF vector
- **Fix:** Added `_GRAPH_HOST` constant and `urllib.parse` hostname check before following any pagination URL
- **Files modified:** `backend/storage/onedrive_backend.py`
- **Commit:** 805fe44

## Test Evidence

| Suite | Tests | Status |
|-------|-------|--------|
| `test_cloud_provider_contract.py` | 48 | All pass |
| `test_cloud_backends.py` | 67 | All pass |
| `test_webdav_backend.py` | 29 | All pass (9 warnings for sync tests with asyncio mark — module-level `pytestmark` issue, tests behave correctly) |
| `test_cloud_security.py` | 19 | All pass |
| Full backend suite | 585 | 1 pre-existing failure (`test_extract_docx` — missing `python-docx` module, unrelated) |

## Security Verification

Bandit scanned `nextcloud_backend.py`, `cloud_utils.py`, `cloud_backend_factory.py`, `onedrive_backend.py` — zero HIGH/MEDIUM/LOW findings.

Threat model coverage:

| Threat ID | Mitigation | Evidence |
|-----------|-----------|---------|
| T-12.1-01 | Provider response fields cannot override connection_id/user_id | `TestProviderHostileIdentityRejection` in contract suite |
| T-12.1-02 | SSRF through Nextcloud URL normalization | `normalize_nextcloud_url` always calls `validate_cloud_url`; HTTPS-only; rejects userinfo |
| T-12.1-03 | Partial page authorizes deletion | `complete=False` on error; `test_provider_page_failure_is_incomplete` |
| T-12.1-04 | Browse downloads or mutates provider content | `test_provider_listing_never_downloads_or_mutates` spies on forbidden methods |
| T-12.1-05 | Secrets in fixtures or error messages | Synthetic fixtures only; controlled exceptions |

## Known Stubs

None — all contract tests use real provider implementations (not mocks of the contract itself).

## Self-Check: PASSED

| Check | Result |
|-------|--------|
| `backend/tests/test_cloud_provider_contract.py` | FOUND |
| `backend/tests/fixtures/cloud/nextcloud_root.xml` | FOUND |
| `backend/storage/nextcloud_backend.py` | FOUND |
| `backend/storage/cloud_utils.py` | FOUND |
| Commit eb68fac (RED tests) | FOUND |
| Commit 2b46f74 (GREEN fix) | FOUND |
| Commit 805fe44 (Drive/OneDrive) | FOUND |
| Commit 6212873 (docs, asyncio fix) | FOUND |
