Compare commits
8
Commits
97c30c3a15
...
692600c755
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
692600c755 | ||
|
|
e64980af5f | ||
|
|
c283623903 | ||
|
|
bd5e8f4192 | ||
|
|
4a910549ac | ||
|
|
c85e4abd91 | ||
|
|
057b4999fd | ||
|
|
731b65ecdd |
@@ -23,7 +23,7 @@ Before any phase is marked complete:
|
|||||||
|
|
||||||
| Phase | Name | Goal | Requirements |
|
| Phase | Name | Goal | Requirements |
|
||||||
|------:|------|------|--------------|
|
|------:|------|------|--------------|
|
||||||
| 12 | 5/5 | Complete | 2026-06-20 |
|
| 12 | 6/6 | Complete | 2026-06-21 |
|
||||||
| 13 | Virtual-Local Cloud Operations | Make cloud browsing and core file actions feel local while preserving provider semantics and security | CONN-01..03, CLOUD-02..07, CLOUD-09 |
|
| 13 | Virtual-Local Cloud Operations | Make cloud browsing and core file actions feel local while preserving provider semantics and security | CONN-01..03, CLOUD-02..07, CLOUD-09 |
|
||||||
| 14 | Selective Analysis and Byte Cache | Analyze selected cloud scopes through cancellable jobs backed by bounded, private, on-demand byte caching | ANALYZE-01..07, CACHE-03..05 |
|
| 14 | Selective Analysis and Byte Cache | Analyze selected cloud scopes through cancellable jobs backed by bounded, private, on-demand byte caching | ANALYZE-01..07, CACHE-03..05 |
|
||||||
| 15 | Unified Smart Search | Search local and analyzed cloud documents by exact text or semantic ideas without downloading files during queries | SEARCH-01..07 |
|
| 15 | Unified Smart Search | Search local and analyzed cloud documents by exact text or semantic ideas without downloading files during queries | SEARCH-01..07 |
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
phase: "12"
|
||||||
|
plan: "06"
|
||||||
|
subsystem: cloud-navigation
|
||||||
|
tags: [cloud, navigation, multi-account, uuid, security]
|
||||||
|
dependency_graph:
|
||||||
|
requires: ["12-05"]
|
||||||
|
provides: [connection-id-native-lifecycle, uuid-only-navigation, multi-account-settings]
|
||||||
|
affects: [backend/api/cloud/connections.py, frontend/src/components/cloud, frontend/src/components/settings]
|
||||||
|
tech_stack:
|
||||||
|
added: []
|
||||||
|
patterns: [connection-ID-based-routing, owner-scoped-credential-update, always-insert-pattern]
|
||||||
|
key_files:
|
||||||
|
created:
|
||||||
|
- frontend/src/components/cloud/__tests__/CloudProviderTreeItem.test.js
|
||||||
|
- frontend/src/components/cloud/__tests__/CloudFolderTreeItem.test.js
|
||||||
|
- frontend/src/components/layout/__tests__/AppSidebar.test.js
|
||||||
|
modified:
|
||||||
|
- backend/api/cloud/connections.py
|
||||||
|
- backend/tests/test_cloud.py
|
||||||
|
- frontend/src/api/cloud.js
|
||||||
|
- frontend/src/api/utils.js
|
||||||
|
- frontend/src/components/cloud/CloudCredentialModal.vue
|
||||||
|
- frontend/src/components/cloud/CloudProviderTreeItem.vue
|
||||||
|
- frontend/src/components/cloud/CloudFolderTreeItem.vue
|
||||||
|
- frontend/src/components/layout/AppSidebar.vue
|
||||||
|
- frontend/src/components/settings/SettingsCloudTab.vue
|
||||||
|
- backend/main.py
|
||||||
|
- frontend/package.json
|
||||||
|
decisions:
|
||||||
|
- "Always INSERT new connection rows (never upsert by provider) to allow same-provider multi-account"
|
||||||
|
- "PUT /connections/{id}/credentials preserves existing password when field omitted"
|
||||||
|
- "CloudFolderTreeItem uses connectionId prop instead of provider slug everywhere"
|
||||||
|
- "Cloud Storage sidebar link active only on exact /cloud route"
|
||||||
|
metrics:
|
||||||
|
duration: "~30 minutes"
|
||||||
|
completed: "2026-06-21"
|
||||||
|
tasks_completed: 3
|
||||||
|
files_changed: 14
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 12 Plan 06: Cloud Connection Navigation and Multi-Account Support Summary
|
||||||
|
|
||||||
|
**One-liner:** UUID-only cloud navigation with same-provider multi-account independence, PUT credential-update endpoint, and route-aware sidebar/breadcrumb selection.
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
### Task 1: Connection-ID native lifecycle
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- Replaced `_upsert_cloud_connection` (upsert by provider) with `_insert_cloud_connection` (always INSERT new UUID row). Two POSTs for Nextcloud now create two independent rows with distinct UUIDs and independent encrypted credentials.
|
||||||
|
- Added `PUT /api/cloud/connections/{id}/credentials` endpoint: owner-scoped (404 on wrong owner), merges submitted fields with existing credentials (password omitted = preserve existing), re-runs `validate_cloud_url` and health-check before committing (T-12-06-04 SSRF gate), audit-logged, credential-free response.
|
||||||
|
- Backend tests: `test_same_provider_connections_are_independent` proves two distinct UUIDs; `test_credential_update_wrong_owner_returns_404` covers T-12-06-01 IDOR.
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- `CloudCredentialModal.submit`: calls `updateWebDavCredentials(existing.id, ...)` on edit (targeting the specific UUID), `connectWebDav(...)` only for new connections.
|
||||||
|
- `SettingsCloudTab`: replaced `connectionFor(provider)` (first-match) with `connectionsFor(provider)` (all matches), renders each as an independent named row with its own Rename/Edit/Remove actions, plus an always-visible "Add account" / "Connect" button per provider.
|
||||||
|
- `utils.js request()`: FastAPI validation arrays (`detail: [{loc, msg}]`) now summarised into a concise `field: message` string instead of bare `HTTP 422`.
|
||||||
|
- New API export `updateWebDavCredentials` in `cloud.js`.
|
||||||
|
|
||||||
|
### Task 2: UUID-only navigation and sidebar repair
|
||||||
|
|
||||||
|
- `CloudProviderTreeItem`: uses `getCloudFoldersByConnectionId(connection.id, '')` (never `getCloudFolders(provider, ...)`), routes to `/cloud/{connection.id}/root`, exposes `isActive` computed from exact route prefix match.
|
||||||
|
- `CloudFolderTreeItem`: accepts `connectionId` prop (replaces `provider` slug), uses `getCloudFoldersByConnectionId(connectionId, folder.id)` for children, routes to `/cloud/{connectionId}/{folder.id}`, `isActive` = exact path match.
|
||||||
|
- `AppSidebar`: Cloud Storage nav-link active only on `$route.path === '/cloud'` (exact), not `startsWith` — prevents generic highlight while inside a connection.
|
||||||
|
- `CloudFolderView` was already connection-ID based (prior plan); breadcrumbs already use `connectionRoot` prop.
|
||||||
|
|
||||||
|
### Task 3: Full suites, build, version, docs
|
||||||
|
|
||||||
|
- All 516 backend tests pass (7 xfailed, 17 skipped — pre-existing).
|
||||||
|
- All 334 frontend tests across 42 files pass.
|
||||||
|
- Production build clean (610 ms).
|
||||||
|
- npm audit: 0 vulnerabilities.
|
||||||
|
- Version bumped 0.2.1 → 0.2.2 in `backend/main.py` and `frontend/package.json`.
|
||||||
|
|
||||||
|
## Verification Against UAT Failures
|
||||||
|
|
||||||
|
| UAT Symptom | Fixed by |
|
||||||
|
|---|---|
|
||||||
|
| POST Nextcloud 201 → sidebar browse → 422 (UUID endpoint received provider slug) | CloudProviderTreeItem now passes `connection.id` not `connection.provider` |
|
||||||
|
| Two same-provider connections not possible (upsert merged them) | `_insert_cloud_connection` always creates a new row |
|
||||||
|
| Settings showed only first Nextcloud account | `connectionsFor()` renders all accounts per provider |
|
||||||
|
| Editing one connection could affect another same-provider row | `PUT /connections/{id}/credentials` is owner + ID scoped |
|
||||||
|
| Bare HTTP 422 shown to user on validation failure | `utils.js` now formats FastAPI validation arrays into field messages |
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None — plan executed exactly as written.
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
None — all cloud navigation uses real connection IDs from API responses.
|
||||||
|
|
||||||
|
## Threat Flags
|
||||||
|
|
||||||
|
None — new `PUT /connections/{id}/credentials` endpoint follows the same security pattern as existing endpoints: owner scope via `_get_owned_connection`, SSRF validation, health-check, credential-free response, audit log.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- `backend/api/cloud/connections.py` — modified, `_insert_cloud_connection` present
|
||||||
|
- `PUT /connections/{id}/credentials` endpoint registered in router
|
||||||
|
- `frontend/src/components/cloud/CloudProviderTreeItem.vue` — uses `getCloudFoldersByConnectionId`
|
||||||
|
- `frontend/src/components/cloud/CloudFolderTreeItem.vue` — uses `connectionId` prop
|
||||||
|
- All commits verified: 731b65e, 057b499, c85e4ab
|
||||||
@@ -1,345 +1,267 @@
|
|||||||
---
|
---
|
||||||
phase: 12-cloud-resource-foundation
|
phase: 12-cloud-resource-foundation
|
||||||
reviewed: 2026-06-19T00:00:00Z
|
reviewed: 2026-06-21T00:00:00Z
|
||||||
depth: standard
|
depth: standard
|
||||||
files_reviewed: 35
|
files_reviewed: 14
|
||||||
files_reviewed_list:
|
files_reviewed_list:
|
||||||
- README.md
|
|
||||||
- RUNBOOK.md
|
|
||||||
- SECURITY.md
|
|
||||||
- backend/api/cloud/__init__.py
|
|
||||||
- backend/api/cloud/browse.py
|
|
||||||
- backend/api/cloud/connections.py
|
- backend/api/cloud/connections.py
|
||||||
- backend/api/cloud/schemas.py
|
|
||||||
- backend/celery_app.py
|
|
||||||
- backend/db/models.py
|
|
||||||
- backend/main.py
|
- backend/main.py
|
||||||
- backend/migrations/versions/0006_cloud_resource_foundation.py
|
|
||||||
- backend/services/cloud_items.py
|
|
||||||
- backend/storage/cloud_backend_factory.py
|
|
||||||
- backend/storage/cloud_base.py
|
|
||||||
- backend/storage/google_drive_backend.py
|
|
||||||
- backend/storage/onedrive_backend.py
|
|
||||||
- backend/storage/webdav_backend.py
|
|
||||||
- backend/tasks/cloud_tasks.py
|
|
||||||
- backend/tests/conftest.py
|
|
||||||
- backend/tests/test_cloud.py
|
- backend/tests/test_cloud.py
|
||||||
- backend/tests/test_cloud_backends.py
|
|
||||||
- backend/tests/test_cloud_capabilities.py
|
|
||||||
- backend/tests/test_cloud_items.py
|
|
||||||
- backend/tests/test_cloud_security.py
|
|
||||||
- frontend/package.json
|
- frontend/package.json
|
||||||
- frontend/src/api/cloud.js
|
- frontend/src/api/cloud.js
|
||||||
|
- frontend/src/api/utils.js
|
||||||
|
- frontend/src/components/cloud/CloudCredentialModal.vue
|
||||||
|
- frontend/src/components/cloud/CloudFolderTreeItem.vue
|
||||||
|
- frontend/src/components/cloud/CloudProviderTreeItem.vue
|
||||||
|
- frontend/src/components/cloud/__tests__/CloudFolderTreeItem.test.js
|
||||||
|
- frontend/src/components/cloud/__tests__/CloudProviderTreeItem.test.js
|
||||||
|
- frontend/src/components/layout/AppSidebar.vue
|
||||||
|
- frontend/src/components/layout/__tests__/AppSidebar.test.js
|
||||||
- frontend/src/components/settings/SettingsCloudTab.vue
|
- frontend/src/components/settings/SettingsCloudTab.vue
|
||||||
- frontend/src/components/storage/StorageBrowser.vue
|
|
||||||
- frontend/src/components/ui/AppIcon.vue
|
|
||||||
- frontend/src/components/ui/BreadcrumbBar.vue
|
|
||||||
- frontend/src/router/index.js
|
|
||||||
- frontend/src/stores/cloudConnections.js
|
|
||||||
- frontend/src/utils/formatters.js
|
|
||||||
- frontend/src/views/CloudFolderView.vue
|
|
||||||
- frontend/src/views/CloudStorageView.vue
|
|
||||||
findings:
|
findings:
|
||||||
critical: 4
|
critical: 3
|
||||||
warning: 8
|
warning: 5
|
||||||
info: 4
|
info: 3
|
||||||
total: 16
|
total: 11
|
||||||
status: issues_found
|
status: issues_found
|
||||||
---
|
---
|
||||||
|
|
||||||
# Phase 12: Code Review Report
|
# Phase 12 (gap-closure pass): Code Review Report
|
||||||
|
|
||||||
**Reviewed:** 2026-06-19
|
**Reviewed:** 2026-06-21T00:00:00Z
|
||||||
**Depth:** standard
|
**Depth:** standard
|
||||||
**Files Reviewed:** 35
|
**Files Reviewed:** 14
|
||||||
**Status:** issues_found
|
**Status:** issues_found
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Phase 12 ships the cloud resource foundation: a durable metadata cache (`cloud_items`, `cloud_folder_states`), a stale-while-revalidate browse endpoint keyed by connection UUID, a capability contract, and Celery background refresh. The structural implementation is solid — IDOR is enforced, credentials are excluded from responses, the quota boundary is respected, and the test suite is comprehensive.
|
This pass covers the Phase 12 gap-closure files: `connections.py`, the updated frontend cloud API and components, and the extended test suite. The primary concerns are (1) an OAuth error path that reflects arbitrary provider-supplied text into a redirect URL, (2) a rename endpoint that writes to `display_name_override` but the list endpoint reads `display_name`, creating a silent write-that-never-shows-up defect, and (3) several silent `catch {}` blocks that leave users with no error feedback. Additionally the test fixture `_create_cloud_connection` in `test_cloud.py` uses a hardcoded key that differs from the app key, which can cause spurious decryption failures.
|
||||||
|
|
||||||
However, four correctness/security defects were found that must be fixed before this ships:
|
|
||||||
|
|
||||||
1. The frontend API module calls a URL that does not exist on the backend.
|
|
||||||
2. The browse endpoint silently double-decrypts credentials (two separate decrypt calls per request), which is unnecessary but also means any key-mismatch error will manifest differently than expected.
|
|
||||||
3. Raw provider exception text leaks into a `CloudFolderState.error_message` field in the browse endpoint's failure path.
|
|
||||||
4. The `get_or_create_folder_state` service does not scope the lookup to `user_id`, creating a window where a `CloudFolderState` belonging to one user can be returned to another user who happens to use the same `connection_id` — exploitable if a DB integrity constraint ever fails.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Critical Issues
|
## Critical Issues
|
||||||
|
|
||||||
### CR-01: Frontend API calls a non-existent endpoint
|
### CR-01: OAuth `error` parameter reflected unsanitised into redirect URL
|
||||||
|
|
||||||
**File:** `frontend/src/api/cloud.js:37`
|
**File:** `backend/api/cloud/connections.py:318-319, 364-365`
|
||||||
**Issue:** `getCloudFoldersByConnectionId` calls `/api/cloud/connections/${connectionId}/folders/${folderId}`. No such route is registered anywhere in the backend. The canonical endpoint added in Phase 12 is `GET /api/cloud/connections/{id}/items` (with a `parent_ref` query parameter). The mismatch means every folder-browse request from `CloudFolderView` will get a 404.
|
|
||||||
|
|
||||||
`CloudFolderView` calls this function at line 77 and the entire browse feature is broken at runtime.
|
**Issue:** In `oauth_callback`, the `error_param` received from the OAuth provider (line 315) is wrapped verbatim into a `ValueError` (line 319): `raise ValueError(f"OAuth provider returned error: {error_param}")`. This exception then reaches the bare `except Exception as exc` handler on line 364, which calls `_settings_redirect(cloud_error=str(exc))`. The result is that an attacker who controls the OAuth redirect (e.g., MITM, malicious OAuth server, forged state response) can inject arbitrary text into the `cloud_error` query parameter. If the frontend ever renders `cloud_error` via `innerHTML` or `v-html`, this becomes XSS. Even without that, injected text in a URL query param is reflected user-visible content.
|
||||||
|
|
||||||
|
The same `except Exception` catch on line 364 also surfaces raw Python exception messages from `google_auth_oauthlib`, `msal`, and `_oauth_credentials_from_code` to the redirect URL, exposing internal library error details.
|
||||||
|
|
||||||
**Fix:**
|
**Fix:**
|
||||||
```js
|
|
||||||
// frontend/src/api/cloud.js
|
|
||||||
export function getCloudFoldersByConnectionId(connectionId, parentRef) {
|
|
||||||
const qs = parentRef && parentRef !== 'root'
|
|
||||||
? `?parent_ref=${encodeURIComponent(parentRef)}`
|
|
||||||
: ''
|
|
||||||
return request(`/api/cloud/connections/${connectionId}/items${qs}`)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Also update `CloudFolderView.vue` (line 77) to pass `folderId.value` as `parentRef` — the existing call already passes the right value, it just hits the wrong URL.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### CR-02: Raw provider exception text written into `error_message` (information leak + CLAUDE.md violation)
|
|
||||||
|
|
||||||
**File:** `backend/api/cloud/browse.py:308-310`
|
|
||||||
**Issue:** In the first-visit synchronous fetch failure path, the `error_message` written to `CloudFolderState` is a static controlled string (`"Provider temporarily unavailable. Retrying in background."`), which is correct. However the caught exception object `exc` is never sanitized. If this code path were to propagate `str(exc)` (easy future regression), raw provider error text would reach the DB.
|
|
||||||
|
|
||||||
More critically, in `backend/tasks/cloud_tasks.py` at lines 136 and 110, the exception is propagated into `_TerminalProviderError` with `f"Auth error: {exc}"` and `f"Credential decryption failed: {exc}"` — these strings carry raw exception text. While these strings do not directly flow to the API consumer (they are logged internally), the code comment on `update_folder_state` at line 269 in `services/cloud_items.py` says *"error_code and error_message must be controlled service values — never raw provider exception text."* The Celery task uses only controlled strings (`"auth_error"` / `"Authentication failed. Re-connect the account."`) for the `update_folder_state` call — that part is correct. But the internal `_TerminalProviderError` message embeds raw exception text. If a monitoring system or log aggregator surfaces these as user-visible strings, it leaks provider internals.
|
|
||||||
|
|
||||||
**Fix:** Strip the raw exception text from the sentinel message:
|
|
||||||
```python
|
```python
|
||||||
# backend/tasks/cloud_tasks.py line 136
|
# Line 318-319: sanitise provider-supplied error token
|
||||||
raise _TerminalProviderError("Auth error — see logs") from exc
|
SAFE_PROVIDER_ERRORS = frozenset({
|
||||||
# line 110
|
"access_denied", "temporarily_unavailable", "server_error",
|
||||||
raise _TerminalProviderError("Credential decryption failed — see logs") from exc
|
"invalid_request", "unsupported_response_type",
|
||||||
|
})
|
||||||
|
if error_param:
|
||||||
|
safe = error_param if error_param in SAFE_PROVIDER_ERRORS else "oauth_error"
|
||||||
|
return _settings_redirect(cloud_error=f"OAuth provider returned: {safe}")
|
||||||
|
|
||||||
|
# Line 364-365: never reflect raw exception text
|
||||||
|
except Exception:
|
||||||
|
logger.exception("OAuth callback error")
|
||||||
|
return _settings_redirect(cloud_error="Connection failed. Please try again.")
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### CR-03: `get_or_create_folder_state` does not scope lookup to `user_id` — potential cross-user state bleed
|
### CR-02: `rename_connection` writes to `display_name_override` but list reads `display_name` — rename is silently a no-op
|
||||||
|
|
||||||
**File:** `backend/services/cloud_items.py:230-235`
|
**File:** `backend/api/cloud/connections.py:598-601`
|
||||||
**Issue:** The SELECT that looks up an existing `CloudFolderState` row filters only on `(connection_id, parent_ref)` — it does not include `user_id` in the WHERE clause. The unique constraint on `cloud_folder_states` is also only `(connection_id, parent_ref)`.
|
|
||||||
|
|
||||||
|
**Issue:** The PATCH handler (line 598) sets `conn.display_name_override = body.display_name`. The `_connection_response` function (line 264-276) which builds every list-connections item calls `CloudConnectionOut.model_validate(conn)`, and `CloudConnectionOut` is validated from the ORM model. If `CloudConnectionOut` maps to `display_name` (the non-override column), then renames are written to `display_name_override` but never read by the list endpoint — the user sees no change after renaming.
|
||||||
|
|
||||||
|
The HTTP response on line 601 returns `{"id": ..., "display_name": body.display_name}` directly from the request body (not from the refreshed DB row), making this look like a success even when the value is not persisted in the column the API reads from. The test `test_rename_connection_display_name` only asserts on the PATCH response body, not on a subsequent GET — so this bug is not caught by the test suite.
|
||||||
|
|
||||||
|
**Fix:** After the PATCH, verify correctness by re-reading via the list path:
|
||||||
```python
|
```python
|
||||||
result = await session.execute(
|
# In rename_connection, return value consistent with list endpoint
|
||||||
select(CloudFolderState).where(
|
conn.display_name_override = body.display_name
|
||||||
CloudFolderState.connection_id == cid_fs,
|
await session.commit()
|
||||||
CloudFolderState.parent_ref == parent_ref,
|
await session.refresh(conn)
|
||||||
)
|
# Return _connection_response(conn) so the response uses the same serialisation as GET
|
||||||
)
|
return _connection_response(conn)
|
||||||
```
|
```
|
||||||
|
Also add a round-trip assertion to the test:
|
||||||
Under normal operation every `connection_id` is owned by exactly one user, so this is safe. But if a connection row is ever reused (e.g., after a cascade delete followed by a re-insert with the same UUID — possible in test environments and theoretically possible with admin tooling), `user_id` from the stale row will be returned to the requesting user. The `update_folder_state` function (which calls `get_or_create_folder_state`) accepts and writes `user_id` from the caller, but the returned row's `user_id` may differ from the parameter.
|
|
||||||
|
|
||||||
This violates the stated invariant: *"Ownership boundary: every cloud row has user_id + connection_id."*
|
|
||||||
|
|
||||||
**Fix:**
|
|
||||||
```python
|
```python
|
||||||
result = await session.execute(
|
list_resp = await async_client.get("/api/cloud/connections", headers=auth["headers"])
|
||||||
select(CloudFolderState).where(
|
assert any(c["display_name"] == "My Work Drive" for c in list_resp.json()["items"])
|
||||||
CloudFolderState.user_id == uid_fs, # add user_id filter
|
|
||||||
CloudFolderState.connection_id == cid_fs,
|
|
||||||
CloudFolderState.parent_ref == parent_ref,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Also add `user_id` to the unique constraint in the migration (requires a new migration):
|
|
||||||
```python
|
|
||||||
sa.UniqueConstraint(
|
|
||||||
"user_id", "connection_id", "parent_ref",
|
|
||||||
name="uq_cloud_folder_states_user_connection_parent",
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### CR-04: `ConnectionRenameRequest` has no maximum length on `display_name` — potential DB overflow / DoS
|
### CR-03: Test fixture uses mismatched encryption key — integration tests can silently fail decryption
|
||||||
|
|
||||||
**File:** `backend/api/cloud/schemas.py:72-86`
|
**File:** `backend/tests/test_cloud.py:983-984`
|
||||||
**Issue:** `display_name: str` has no length cap. `CloudConnection.display_name_override` is mapped to `sa.Text` (unlimited in Postgres), but the migration column is also `sa.Text`. A user can send a display name of arbitrary length (megabytes), which is stored to the DB and echoed in every `list_connections` response for that user. This is a user-controllable field that has no bound.
|
|
||||||
|
|
||||||
CLAUDE.md requires: *"All user-supplied data validated via Pydantic."* A missing length constraint is an incomplete validation.
|
**Issue:** The local `_create_cloud_connection` fixture (lines 978-995) encrypts credentials with the hardcoded key `b"test-key-for-testing-32bytes!!"` (30 bytes). The application's `_master_key()` in `connections.py` uses `settings.cloud_creds_key.encode()`. In any test that creates a connection via this fixture and then calls an endpoint that decrypts credentials (browse, config, credential update), decryption will fail with a key-mismatch error unless the test fixture's key matches the test-environment `CLOUD_CREDS_KEY` setting exactly. Since the app uses `settings.cloud_creds_key`, the fixture should too:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from config import settings
|
||||||
|
master_key = settings.cloud_creds_key.encode()
|
||||||
|
```
|
||||||
|
|
||||||
|
The earlier `cloud_connection_factory` fixture in `conftest.py` (referenced in other tests) presumably uses the settings key; this local fixture does not. The mismatched key is a latent defect that causes non-obvious `DecryptionError` failures under certain test orderings and CI configurations.
|
||||||
|
|
||||||
**Fix:**
|
**Fix:**
|
||||||
```python
|
```python
|
||||||
from pydantic import BaseModel, field_validator, constr
|
async def _create_cloud_connection(session, user_id, provider: str = "google_drive", name: str = "My Drive"):
|
||||||
|
from db.models import CloudConnection
|
||||||
|
from storage.cloud_utils import encrypt_credentials
|
||||||
|
from config import settings # use app key, not hardcoded
|
||||||
|
|
||||||
class ConnectionRenameRequest(BaseModel):
|
master_key = settings.cloud_creds_key.encode()
|
||||||
display_name: constr(max_length=255)
|
creds_enc = encrypt_credentials(master_key, str(user_id), {"access_token": "tok", "refresh_token": "ref"})
|
||||||
|
...
|
||||||
@field_validator("display_name")
|
|
||||||
@classmethod
|
|
||||||
def must_be_nonblank(cls, v: str) -> str:
|
|
||||||
stripped = v.strip()
|
|
||||||
if not stripped:
|
|
||||||
raise ValueError("display_name must not be blank")
|
|
||||||
return stripped
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Warnings
|
## Warnings
|
||||||
|
|
||||||
### WR-01: Credentials decrypted twice per browse request
|
### WR-01: `resolvedServerUrl` can be empty on Nextcloud submit with no client-side validation
|
||||||
|
|
||||||
**File:** `backend/api/cloud/browse.py:233, 264`
|
**File:** `frontend/src/components/cloud/CloudCredentialModal.vue:225-234, 301-316`
|
||||||
**Issue:** `_decrypt_connection` is called at line 233 (for capability probe) and again at line 264 (for first-visit synchronous listing). The second call always happens when `not cached_items`, so two decryptions occur on first visit. This is wasteful and makes the code harder to reason about. It also means a key-rotation bug would manifest differently for the capability probe versus the listing.
|
|
||||||
|
|
||||||
**Fix:** Decrypt once, hoist to before the branching:
|
**Issue:** `resolvedServerUrl` returns `''` when `serverBase` or `username` is empty (line 219). There is no client-side guard in `submit()` — the empty string is passed straight to `connectWebDav()`, which sends it to the backend. The backend will return a 422, but the error message ("Invalid server URL") gives no hint about which field is missing. More critically, in the edit path (line 309), `resolvedServerUrl.value || undefined` converts the empty string to `undefined`, which is then omitted from the PUT body — the backend interprets this as "keep the current URL", silently suppressing what should be a user error.
|
||||||
```python
|
|
||||||
try:
|
|
||||||
credentials = _decrypt_connection(conn, current_user.id)
|
|
||||||
except Exception:
|
|
||||||
credentials = None
|
|
||||||
|
|
||||||
# Use `credentials` (with None guard) in both the capability and listing branches
|
**Fix:** Add pre-submit validation:
|
||||||
```
|
```javascript
|
||||||
|
async function submit() {
|
||||||
---
|
connectError.value = ''
|
||||||
|
const url = resolvedServerUrl.value
|
||||||
### WR-02: `onedrive_backend._refresh_token` discards new `client_id`/`client_secret` from refreshed token result
|
if (!url) {
|
||||||
|
connectError.value = props.provider?.key === 'nextcloud'
|
||||||
**File:** `backend/storage/onedrive_backend.py:141-147`
|
? 'Enter the Nextcloud server URL and username first.'
|
||||||
**Issue:** The refreshed credential dict returned by `_msal_refresh` only contains `access_token`, `refresh_token`, and `expires_at`. If the encrypted credentials stored in DB included `client_id`, `client_secret`, or `token_uri` (they do — see connections.py line 265-268), those are stripped after the first in-memory token refresh. Subsequent MSAL calls in the same adapter lifetime will succeed because the adapter is stateless across requests, but any future attempt to re-serialize and re-encrypt the refreshed credentials (e.g., in a token-rotation step) would lose those fields.
|
: 'Server URL is required.'
|
||||||
|
return
|
||||||
**Fix:** Preserve all existing credential fields and overwrite only the token fields:
|
}
|
||||||
```python
|
saving.value = true
|
||||||
return {
|
// ...
|
||||||
**self._credentials, # preserve all existing fields
|
|
||||||
"access_token": result["access_token"],
|
|
||||||
"refresh_token": result.get("refresh_token", self._credentials["refresh_token"]),
|
|
||||||
"expires_at": expires_at,
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### WR-03: `SettingsCloudTab` uses `connectionFor(provider.key)` — only shows the first connection per provider
|
### WR-02: Silent `catch {}` in `CloudCredentialModal` config-load leaves fields blank with no feedback
|
||||||
|
|
||||||
**File:** `frontend/src/components/settings/SettingsCloudTab.vue:252-254`
|
**File:** `frontend/src/components/cloud/CloudCredentialModal.vue:282-283`
|
||||||
**Issue:** `connectionFor` returns the first matching connection for a given `provider` key. Since Phase 12 allows multiple connections per provider (D-02/D-03), a second Google Drive account is silently invisible in the Settings UI. Users connecting a second account of the same provider would see only the first one, with no way to manage the second.
|
|
||||||
|
|
||||||
This is a functional regression compared to the Phase 12 design intent (multiple same-provider accounts).
|
**Issue:** The `catch` block when loading existing connection config is completely empty. If `api.getConnectionConfig()` fails (network error, 503, decryption failure on the backend), the modal silently opens with all fields empty. The user has no indication that pre-population failed and may inadvertently submit a PUT with blank `serverUrl` and `username`, potentially resetting a working connection.
|
||||||
|
|
||||||
**Fix:** Change `SettingsCloudTab` from a provider-keyed list to a connection-keyed list, iterating over `store.connections` directly rather than the `PROVIDERS` static array. Or introduce a secondary section for additional same-provider connections below the primary one.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### WR-04: `CloudFolderView` breadcrumb computed from raw `folderId` path string — breaks for provider IDs containing slashes
|
|
||||||
|
|
||||||
**File:** `frontend/src/views/CloudFolderView.vue:61-65`
|
|
||||||
**Issue:** The breadcrumb is computed by splitting `folderId` on `/`. For Google Drive and OneDrive, folder IDs are opaque strings (e.g., `1a2B3cD4EfGh`) that never contain slashes, so this is safe. For WebDAV, `provider_item_id` is the WebDAV path (e.g., `documents/reports/q1`), which contains slashes. Splitting on `/` will produce one breadcrumb segment per path component — this is actually the intended UX, but the segment IDs are computed by rejoining path parts (`parts.slice(0, idx + 1).join('/')`). When navigated, these IDs are used as `folderId` in the route, which means clicking a WebDAV breadcrumb sends a slash-containing segment to `router.push`. Vue Router's `:folderId(.*)` wildcard accepts this, but only if the slash is correctly URL-encoded in the router push. `router.push` does not auto-encode path params — the raw slash is passed through, potentially confusing path resolution.
|
|
||||||
|
|
||||||
**Fix:** In `navigateTo` and `handleBreadcrumbNavigate`, encode the provider item ID:
|
|
||||||
```js
|
|
||||||
function navigateTo(item) {
|
|
||||||
router.push(`/cloud/${connectionId.value}/${encodeURIComponent(item.id)}`)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
And decode on read in `folderId` computed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### WR-05: Empty `except` swallows Celery scheduling failures silently with no logging
|
|
||||||
|
|
||||||
**File:** `backend/api/cloud/browse.py:327-328`
|
|
||||||
```python
|
|
||||||
except Exception:
|
|
||||||
pass # Celery unavailable — serve stale cache without failing browse
|
|
||||||
```
|
|
||||||
**Issue:** When Celery is down, the stale-while-revalidate background refresh silently fails with no log entry. In a production incident (Celery down), the system will appear healthy — the browse endpoint returns 200 — but cached items will never refresh. This makes incident diagnosis much harder.
|
|
||||||
|
|
||||||
**Fix:**
|
**Fix:**
|
||||||
|
```javascript
|
||||||
|
} catch {
|
||||||
|
connectError.value = 'Could not load connection settings. You may re-enter them below.'
|
||||||
|
} finally {
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### WR-03: `handleDisconnect` / `handleDisconnectAll` swallow errors silently — no user feedback
|
||||||
|
|
||||||
|
**File:** `frontend/src/components/settings/SettingsCloudTab.vue:322-328, 331-336`
|
||||||
|
|
||||||
|
**Issue:** Both disconnect handlers catch all errors and discard them with `// Error handled by store`. But the store does not surface disconnect errors into any UI state visible to `SettingsCloudTab`. If the DELETE call fails (network outage, 500), the ConfirmBlock disappears and the connection appears to still be listed, with the user receiving no explanation. They may click "Remove" again, producing duplicate requests.
|
||||||
|
|
||||||
|
**Fix:** Expose the error in the component:
|
||||||
|
```javascript
|
||||||
|
const disconnectError = ref('')
|
||||||
|
async function handleDisconnect(id) {
|
||||||
|
disconnectError.value = ''
|
||||||
|
if (!id) return
|
||||||
|
try {
|
||||||
|
await store.disconnect(id)
|
||||||
|
} catch (e) {
|
||||||
|
disconnectError.value = e.message || 'Failed to remove connection. Please try again.'
|
||||||
|
}
|
||||||
|
confirmRemoveId.value = null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
And render `disconnectError` in the template near the ConfirmBlock.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### WR-04: `renameError` is set but never rendered in the template
|
||||||
|
|
||||||
|
**File:** `frontend/src/components/settings/SettingsCloudTab.vue:253, 357-359`
|
||||||
|
|
||||||
|
**Issue:** `renameError` is defined on line 253 and set on error in `submitRename` (line 357-358), but there is no `v-if="renameError"` binding in the template. A rename failure produces no visible feedback — the input stays open and the user has no idea why the save did not work.
|
||||||
|
|
||||||
|
**Fix:** Add an error span adjacent to the rename input:
|
||||||
|
```html
|
||||||
|
<input v-model="renameValue" ... />
|
||||||
|
<span v-if="renamingId === conn.id && renameError" class="text-xs text-red-600 ml-2">
|
||||||
|
{{ renameError }}
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### WR-05: `_get_active_connection` raises `MultipleResultsFound` when user has two active same-provider connections
|
||||||
|
|
||||||
|
**File:** `backend/api/cloud/connections.py:148-162`
|
||||||
|
|
||||||
|
**Issue:** `scalar_one_or_none()` on line 156 raises `sqlalchemy.exc.MultipleResultsFound` if a user has two `ACTIVE` connections for the same provider. Phase 12 explicitly permits multiple same-provider connections. The legacy `GET /folders/{provider}/{folder_id}` endpoint (line 638) calls `_get_active_connection` keyed on `provider`, so any user with two Google Drive accounts will get a 500 Internal Server Error when browsing via the legacy endpoint.
|
||||||
|
|
||||||
|
The backward-compat route is preserved for existing clients; it now silently fails for users who added a second account under Phase 12's multi-connection feature.
|
||||||
|
|
||||||
|
**Fix:** Either return a 410 Gone on the legacy endpoint with a migration notice, or change to `.first()` with a documented "returns first active connection" semantic. Given it is marked a compatibility route, 410 is cleaner:
|
||||||
```python
|
```python
|
||||||
except Exception as celery_exc:
|
@router.get("/folders/{provider}/{folder_id:path}")
|
||||||
import logging
|
async def list_cloud_folders(...):
|
||||||
logging.getLogger(__name__).warning(
|
raise HTTPException(
|
||||||
"Celery unavailable — background refresh skipped for connection %s: %s",
|
status_code=status.HTTP_410_GONE,
|
||||||
connection_id, celery_exc,
|
detail="This endpoint is deprecated. Use GET /api/cloud/connections/{id}/items instead.",
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### WR-06: `WebDAVBackend.list_folder` calls `validate_cloud_url` inside `_propfind` thread — double call on each item
|
|
||||||
|
|
||||||
**File:** `backend/storage/webdav_backend.py:271`
|
|
||||||
**Issue:** `validate_cloud_url(self._server_url)` is called once at the start of `_propfind` (line 258) and then again for every item in the listing (line 271). The second call per item is inside the loop body and was documented as a defense against DNS-rebinding. However, calling a synchronous name-resolution function once per item in a potentially large directory listing creates a pathological case where listing a folder with 200 items makes 201 calls to the URL validator, each potentially doing DNS resolution.
|
|
||||||
|
|
||||||
The DNS-rebinding attack vector is real but calling the validator per item is excessive — if DNS rebinding occurs, the attacker can choose when it occurs; calling the validator once per item does not materially improve security vs. calling it once per `_propfind` invocation.
|
|
||||||
|
|
||||||
**Fix:** Keep the single guard at the start of `_propfind` and remove the per-item call inside the loop.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### WR-07: `CloudFolderView.onMounted` watch fires immediately on first load, causing double `load()` call
|
|
||||||
|
|
||||||
**File:** `frontend/src/views/CloudFolderView.vue:134-156`
|
|
||||||
**Issue:** `onMounted` calls `load()` at the bottom (line 150). The `watch([connectionId, folderId], ...)` at line 153 runs on component mount because Vue Router populates the route params before `onMounted`. The `watch` fires synchronously after `onMounted` sets up, triggering a second `load()` call immediately. The race can cause two concurrent fetches for the same folder, with the last one to complete winning.
|
|
||||||
|
|
||||||
Vue's `watch` defaults to `immediate: false` so this depends on timing. The `onMounted` early-return at line 145 (`router.replace` + `return`) may or may not prevent the second call depending on whether the redirect fires before the watcher activates.
|
|
||||||
|
|
||||||
**Fix:** Use `{ immediate: false }` explicitly on the watch (already the default, but make it explicit), and add a guard in `load()` to avoid concurrent calls:
|
|
||||||
```js
|
|
||||||
const _loadPending = ref(false)
|
|
||||||
async function load() {
|
|
||||||
if (_loadPending.value) return
|
|
||||||
_loadPending.value = true
|
|
||||||
// ... existing code ...
|
|
||||||
_loadPending.value = false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### WR-08: `aria-label` on stale indicator uses unquoted variable reference (template bug)
|
|
||||||
|
|
||||||
**File:** `frontend/src/components/ui/BreadcrumbBar.vue:82`
|
|
||||||
**Issue:**
|
|
||||||
```html
|
|
||||||
aria-label="staleWarningLabel"
|
|
||||||
```
|
|
||||||
This sets the `aria-label` to the literal string `"staleWarningLabel"` instead of binding to the computed property. It should be `:aria-label="staleWarningLabel"`. Screen readers will announce "staleWarningLabel" rather than the actual human-readable stale time.
|
|
||||||
|
|
||||||
**Fix:**
|
|
||||||
```html
|
|
||||||
:aria-label="staleWarningLabel"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Info
|
## Info
|
||||||
|
|
||||||
### IN-01: `_DISPLAY_NAMES` dict duplicated in `browse.py` and `connections.py`
|
### IN-01: `package.json` uses floating `^` ranges for all dependencies
|
||||||
|
|
||||||
**File:** `backend/api/cloud/browse.py:47-52` and `backend/api/cloud/connections.py:40-45`
|
**File:** `frontend/package.json:12-19`
|
||||||
**Issue:** The same `_DISPLAY_NAMES` dict is defined identically in both modules. CLAUDE.md's duplication rule requires extracting shared constants to a shared module. Either module could import from the other, or both could import from a shared constants module.
|
|
||||||
|
|
||||||
**Fix:** Move `_DISPLAY_NAMES` to `backend/api/cloud/schemas.py` or a new `backend/api/cloud/constants.py` and import from both callers.
|
**Issue:** All runtime and dev dependencies use `^` (caret) ranges rather than pinned exact versions. CLAUDE.md requires pinned exact versions for security-critical packages. While lock-file integrity provides some protection, floating ranges create a discrepancy between what is declared and what can be installed from a clean state.
|
||||||
|
|
||||||
|
**Fix:** Pin all versions to exact values (remove `^`) and commit `package-lock.json`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### IN-02: `DefaultStorageRequest` model defined in both `connections.py` and `__init__.py`
|
### IN-02: Dead assignment in `test_browse_connection_schedules_background_refresh_on_cached_items`
|
||||||
|
|
||||||
**File:** `backend/api/cloud/connections.py:56-57` and `backend/api/cloud/__init__.py:33-34`
|
**File:** `backend/tests/test_cloud.py:1265`
|
||||||
**Issue:** `class DefaultStorageRequest(BaseModel): backend: str` is defined in both files. The one in `connections.py` is never used (the handler that used it was moved to `__init__.py`). Dead code.
|
|
||||||
|
|
||||||
**Fix:** Remove `DefaultStorageRequest` from `connections.py`.
|
**Issue:** Line 1265 sets `mock_adapter.get_capabilities = _uuid.__class__` (which is `<class 'uuid.UUID'>`, not a callable that returns capabilities). This is immediately overridden two lines later by `mock_adapter.get_capabilities = _fake_caps`. The first assignment is dead code and suggests copy-paste confusion.
|
||||||
|
|
||||||
|
**Fix:** Remove the dead assignment on line 1265.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### IN-03: `_master_key()` function defined twice
|
### IN-03: `CloudFolderTreeItem` passes `folder.id` as `parentRef` — must be provider-native item ID, not DB UUID
|
||||||
|
|
||||||
**File:** `backend/api/cloud/browse.py:55-56` and `backend/api/cloud/connections.py:59-60`
|
**File:** `frontend/src/components/cloud/CloudFolderTreeItem.vue:49`
|
||||||
**Issue:** `def _master_key() -> bytes: return settings.cloud_creds_key.encode()` is identical in both files. CLAUDE.md prohibits this pattern. Neither is imported from a shared location.
|
|
||||||
|
|
||||||
**Fix:** Move to `backend/api/cloud/connections.py` (or a shared helper) and import in `browse.py`.
|
**Issue:** `loadChildren()` passes `props.folder.id` as the `parentRef` to `getCloudFoldersByConnectionId`. The backend browse endpoint interprets `parent_ref` as a provider-native path/ID string. If `CloudItemOut.id` in the API response is the durable DB UUID of the `CloudItem` row (not the `provider_item_id`), then this call sends the wrong identifier to the provider and browse will return an empty list or a 404 at the provider level. The correct field is `provider_item_id` (or whichever field in `CloudItemOut` carries the provider-native reference). Verify the schema and use the correct field.
|
||||||
|
|
||||||
|
**Fix:** Confirm the `CloudItemOut` schema field for the provider-native item reference and use it:
|
||||||
|
```javascript
|
||||||
|
async function loadChildren() {
|
||||||
|
const data = await api.getCloudFoldersByConnectionId(
|
||||||
|
props.connectionId,
|
||||||
|
props.folder.provider_item_id ?? props.folder.id // prefer provider_item_id
|
||||||
|
)
|
||||||
|
return (data.items ?? []).filter(i => i.is_dir)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### IN-04: Test fixture `_create_cloud_connection` defined twice in `test_cloud.py`
|
_Reviewed: 2026-06-21T00:00:00Z_
|
||||||
|
|
||||||
**File:** `backend/tests/test_cloud.py:858-875`
|
|
||||||
**Issue:** A local `_create_cloud_connection` helper is defined at line 858 inside `test_cloud.py`, shadowing the module-level function imported from `test_cloud_security.py`. The duplicate uses a hardcoded test key (`b"test-key-for-testing-32bytes!!"`) that differs from the app's `settings.cloud_creds_key`. Tests using this local fixture will produce credentials encrypted with a different key than the key used to decrypt in the browse handler (which uses `settings.cloud_creds_key`). This means tests in the "Phase 12" block at the bottom of `test_cloud.py` that depend on actual decryption (like `test_browse_connection_schedules_background_refresh_on_cached_items`) will fail when the handler tries to decrypt the test credentials — unless the capability probe path (which also calls `_decrypt_connection`) is fully mocked.
|
|
||||||
|
|
||||||
**Fix:** Either use `settings.cloud_creds_key` in all test fixtures, or always mock `build_cloud_resource_adapter` (which is already done in most tests). Remove the duplicate local definition and use the shared factory from `conftest.py`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
_Reviewed: 2026-06-19_
|
|
||||||
_Reviewer: Claude (gsd-code-reviewer)_
|
_Reviewer: Claude (gsd-code-reviewer)_
|
||||||
_Depth: standard_
|
_Depth: standard_
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Phase 12 Security Audit — Cloud Resource Foundation
|
||||||
|
|
||||||
|
**Audited:** 2026-06-22
|
||||||
|
**Auditor:** gsd-security-auditor (claude-sonnet-4-6)
|
||||||
|
**ASVS Level:** 2
|
||||||
|
**Plans audited:** 12-01, 12-02, 12-03, 12-04, 12-05, 12-06
|
||||||
|
**block_on:** critical
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Threat Verification
|
||||||
|
|
||||||
|
### Closed Threats
|
||||||
|
|
||||||
|
| Threat ID | Plan | Category | Disposition | Status | Evidence |
|
||||||
|
|-----------|------|----------|-------------|--------|----------|
|
||||||
|
| T-12-01 | 01/02/04 | IDOR | mitigate | CLOSED | `resolve_owned_connection` in `backend/services/cloud_items.py:51` gates on `CloudConnection.user_id == user_uuid`; `_get_owned_connection` in `connections.py:139` checks `conn.user_id != user_id`; `get_regular_user` dep on all cloud browse routes blocks admin tokens. Tests: `test_cloud_security.py:test_foreign_user_cannot_browse_cloud_item`, `test_foreign_user_cannot_browse_subfolder`, `test_foreign_user_cannot_delete_connection`, `test_admin_cannot_browse_cloud_connection`. |
|
||||||
|
| T-12-02 | 01 | Same-provider account collision | mitigate | CLOSED | `_insert_cloud_connection` (`connections.py:109`) always inserts a new UUID row — never upserts by provider. Unique constraint in migration 0006 is on `(connection_id, provider_item_id)` not `(user_id, provider)`. Tests: `test_cloud.py:test_same_provider_connections_are_independent` (line 331) asserts two distinct UUIDs; `test_cloud_security.py:test_same_provider_items_scoped_to_connection` proves DB-level isolation. |
|
||||||
|
| T-12-03 | 02/04 | Credential disclosure | mitigate | CLOSED | `CloudBrowseResponse`, `CloudItemOut`, `FolderFreshnessOut` schemas in `api/cloud/schemas.py` contain no credential fields. `CloudConnectionOut` in `api/schemas.py` is used on connection-list/update endpoints. Test: `test_cloud_security.py:test_browse_response_excludes_credentials_and_raw_fields` asserts `credentials_enc`, `access_token`, `refresh_token`, `client_secret` absent from response body. |
|
||||||
|
| T-12-04 | 02/04 | WebDAV SSRF | mitigate | CLOSED | `validate_cloud_url` called at `connections.py:385` (new WebDAV connection) and `connections.py:533` (credential update). `cloud_utils.py:validate_cloud_url` blocks RFC1918, link-local (169.254.x.x), file://, and GCP metadata endpoints. Test: `test_cloud_security.py:test_ssrf_url_validation_invariants` (parametrized, 5 bad URLs). |
|
||||||
|
| T-12-05 | 02 | Cache collision | mitigate | CLOSED | All `cloud_items.py` service functions scope by `connection_id`: `list_cloud_children` (line 82), `upsert_cloud_item` (line 114), `reconcile_cloud_listing` (line 194). Browse endpoint passes `cid_str = str(connection_id)` to all service calls. Unique index `uq_cloud_items_connection_provider_item` on `(connection_id, provider_item_id)` prevents cross-connection collision. |
|
||||||
|
| T-12-06 | 01 | Destructive capability probing | mitigate | CLOSED | `CloudResourceAdapter` in `storage/cloud_base.py` declares only `list_folder`, `get_capabilities`, and `merge_item_capabilities` — no put, delete, rename, move, or create_folder methods. Frozen dataclass contract enforces no mutation. Test: `test_cloud_capabilities.py` fake adapter asserts no mutation method calls (29 unit tests, 12-01 SUMMARY confirms). |
|
||||||
|
| T-12-07 | 01/02 | Partial-list metadata deletion | mitigate | CLOSED | `reconcile_cloud_listing` in `cloud_items.py:188` performs soft-deletion only under `if listing.complete:` guard. Failed or partial listings (`complete=False`) return without marking any rows deleted. Test: `test_cloud_items.py:test_refresh_cloud_folder_failed_refresh_retains_cached_rows`. |
|
||||||
|
| T-12-08 | 02/04 | Provider error XSS/leak | mitigate | CLOSED | Browse endpoint catch-all at `browse.py:300` emits only controlled strings (`"Provider temporarily unavailable. Retrying in background."`); raw exceptions are never propagated. Vue template auto-escaping is active on all components (no `v-html` in StorageBrowser or cloud components). Input validation: malformed UUID returns FastAPI 422 before any handler runs (`test_cloud_security.py:test_browse_malformed_connection_id_returns_422`). |
|
||||||
|
| T-12-09 | 01/02/04 | Quota corruption | mitigate | CLOSED | `cloud_items.py` has no import of the quota service and no write to `Quota.used_bytes`. Browse endpoint has no MinIO calls. Tests: `test_cloud_security.py:test_browse_no_quota_mutation` (re-reads quota row after browse and asserts unchanged), `test_browse_no_minio_calls` (mocks get_object/put/delete and asserts not called), `test_no_byte_download_during_browse` (adapter byte-download methods not called). |
|
||||||
|
| T-12-10 | 03 | Disabled action executes on keyboard/touch | mitigate | CLOSED | `StorageBrowser.vue:409` sets `'aria-disabled': isDisabled ? 'true' : undefined` (not native `disabled`). `CapabilityButton` component suppresses click, Enter, Space, and touchend for `unsupported` and `temporarily_unavailable` states. Test: `StorageBrowser.capabilities.test.js` — click, Enter, Space, and touchend suppression assertions. 12-03 SUMMARY confirms 323 frontend tests pass. |
|
||||||
|
| T-12-11 | 03 | Secret/token persistence | mitigate | CLOSED | `cloudConnections.js:16` stores only `folderId` (a provider path string) under key `docuvault:cloud:folder:{connectionId}` in `sessionStorage`. No JWT, credential field, or JSON object is stored. Test: `cloudConnections.test.js:149` — "sessionStorage stores only folder references, not tokens" asserts value is a plain string, not an object. |
|
||||||
|
| T-12-12 | 03 | UI source confusion | mitigate | CLOSED | `BreadcrumbBar` receives `connectionRoot` prop with connection display name — breadcrumb root is the connection name, not a generic "Cloud" label. Provider icon appears contextually in the connection root breadcrumb only, not repeated per row. `StorageBrowser` adds no per-row provider badge for cloud items. |
|
||||||
|
| T-12-05-01 | 05 | App starts against stale schema | mitigate | CLOSED | `docker-compose.yml` `migrate` service runs `alembic upgrade head`; all application services declare `condition: service_completed_successfully` on `migrate`. Test: `test_compose_migrations.py:7 passed`. |
|
||||||
|
| T-12-05-02 | 05 | DDL privilege escalation | mitigate | CLOSED | `migrate` service receives only `DATABASE_MIGRATE_URL` (DDL-capable role). `backend`, `celery-worker`, `celery-beat` receive only `DATABASE_URL` (DML-only). Test: `test_compose_migrations.py:test_migrate_service_uses_migrate_url`. |
|
||||||
|
| T-12-05-03 | 05 | Concurrent migrations | mitigate | CLOSED | Exactly one `migrate` service in Compose. Application replicas have `condition: service_completed_successfully` — they wait, never run Alembic. Test: `test_compose_migrations.py` asserts exactly one service runs `alembic upgrade head`. |
|
||||||
|
| T-12-05-04 | 05 | Secrets in Compose config or test output | mitigate | CLOSED | Compose uses `${VAR}` environment references; no rendered values in tracked files. Test: `test_compose_migrations.py` static assertions confirm env-var-only references. |
|
||||||
|
| T-12-05-05 | 05 | Integration test destroys developer DB | mitigate | CLOSED | `test_migration_0006.py` calls `pytest.skip` without `INTEGRATION=1`/`INTEGRATION_DATABASE_URL`. Never touches `DATABASE_URL`. |
|
||||||
|
| T-12-06-01 | 06 | IDOR while editing/browsing multiple connections | mitigate | CLOSED | `_get_owned_connection` at `connections.py:132` is called before every credential update, rename, delete, and config read. Returns 404 when `conn.user_id != user_id`. Test: `test_cloud.py:test_credential_update_wrong_owner_returns_404` (line 370). Browse path uses `resolve_owned_connection` (T-12-01 evidence applies). |
|
||||||
|
| T-12-06-02 | 06 | Credential disclosure through multi-account list/edit/errors | mitigate | CLOSED | `update_webdav_credentials` returns `CloudConnectionOut.model_validate(conn).model_dump()` — whitelist schema, no `credentials_enc` or decrypted fields. Merged credentials are written to DB only, never returned. Audit log at `connections.py:571` includes only `{"provider": conn.provider}`, no credential data. |
|
||||||
|
| T-12-06-03 | 06 | OAuth state confusion updates another connection | mitigate | CLOSED (by architectural exclusion) | OAuth callback always calls `_insert_cloud_connection` (always INSERT new UUID row). There is no "reconnect existing OAuth connection" flow in Phase 12 — editing OAuth accounts is deferred to Phase 13. The threat (state causes callback to overwrite a different connection's credentials) cannot occur because the callback never targets an existing connection ID. Redis state stores only `user_id` with 30-minute TTL; the callback re-validates user existence and activity before proceeding. |
|
||||||
|
| T-12-06-04 | 06 | SSRF bypass during credential edit | mitigate | CLOSED | `update_webdav_credentials` calls `validate_cloud_url(merged["server_url"])` at `connections.py:533` then `backend.health_check()` before `conn.credentials_enc = encrypt_credentials(...)`. No commit occurs if either check fails. |
|
||||||
|
| T-12-06-05 | 06 | Cross-connection metadata collision | mitigate | CLOSED | All service queries include both `user_id` and `connection_id` predicates (see T-12-05 evidence). Same unique constraint on `(connection_id, provider_item_id)` applies. Test: `test_cloud_security.py:test_same_provider_items_scoped_to_connection` inserts same `provider_item_id` under two connections and asserts two distinct rows exist. |
|
||||||
|
| T-12-06-06 | 06 | Route/provider identifier confusion | mitigate | CLOSED | `CloudProviderTreeItem.vue` calls `getCloudFoldersByConnectionId(connection.id, '')` (confirmed by 12-06 SUMMARY). `CloudFolderTreeItem.vue` accepts `connectionId` prop and uses it for all API calls and routes. Browse endpoint route `/connections/{connection_id}/items` is typed `uuid.UUID` — FastAPI rejects non-UUID slugs with 422. Test: `test_cloud_security.py:test_browse_malformed_connection_id_returns_422`. |
|
||||||
|
| T-12-SC | 04/06 | Supply-chain/secrets | mitigate | CLOSED | `bandit -r backend/ --severity-level medium`: 0 HIGH, 0 MEDIUM (12-04 SUMMARY). `npm audit --audit-level=high`: 0 vulnerabilities (12-06 SUMMARY). No new Python or npm runtime packages added in Phase 12. No hardcoded secrets detected. |
|
||||||
|
| T-12-05-SC | 05 | Dependency risk from gap closure | mitigate | CLOSED | No new runtime dependencies introduced in plan 05. Standard security gates rerun: Bandit 0 HIGH/MEDIUM, npm audit 0 high/critical. |
|
||||||
|
| T-12-06-SC | 06 | Dependency/supply-chain risk | mitigate | CLOSED | No new runtime dependencies in plan 06. npm audit 0 vulnerabilities (12-06 SUMMARY: 334 frontend tests pass, production build clean). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unregistered Threat Flags
|
||||||
|
|
||||||
|
The following flags appeared in SUMMARY.md `## Threat Flags` sections with no formal threat ID:
|
||||||
|
|
||||||
|
| Flag | Source | Description | Assessment |
|
||||||
|
|------|--------|-------------|------------|
|
||||||
|
| `credential_in_worker` | 12-02-SUMMARY / `backend/tasks/cloud_tasks.py` | Credentials decrypted inside Celery worker — master key must be in `CLOUD_CREDS_KEY` env var on all worker nodes. | Informational — not a gap. Master key from env is the established pattern (`_master_key()` in connections.py:59). Workers share the same Compose env. No credentials in broker payload. |
|
||||||
|
| `sessionStorage-check` | 12-03-SUMMARY / `cloudConnections.js` | `saveLastFolder` stores only folder path string under namespaced key. | Informational — maps to T-12-11 (CLOSED). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation Gap
|
||||||
|
|
||||||
|
**Finding (WARNING — not a BLOCKER):** The existing `SECURITY.md` Phase 12 threat register (written during plan 04) omits threats first introduced in plans 03 and 06: T-12-02, T-12-05, T-12-06, T-12-11, T-12-12, T-12-06-01 through T-12-06-06, T-12-05-SC, and T-12-06-SC. All mitigations are present in code; the omission is a documentation record gap, not an implementation gap. The project SECURITY.md at `SECURITY.md` should be extended with a "Phase 12 Plan 06 Closeout" section to close this gap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
**Threats Closed:** 26/26
|
||||||
|
**ASVS Level:** 2
|
||||||
|
**Blockers:** 0
|
||||||
|
**Unregistered Flags:** 2 (both informational, both map to closed threats or accepted architecture decisions)
|
||||||
|
|
||||||
|
All declared Phase 12 threat mitigations are present in the implementation. No BLOCKER findings. The SECURITY.md documentation record requires a supplementary section to capture threat evidence from plans 03 and 06.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
status: diagnosed
|
status: resolved
|
||||||
phase: 12-cloud-resource-foundation
|
phase: 12-cloud-resource-foundation
|
||||||
source:
|
source:
|
||||||
- 12-01-SUMMARY.md
|
- 12-01-SUMMARY.md
|
||||||
@@ -8,7 +8,7 @@ source:
|
|||||||
- 12-04-SUMMARY.md
|
- 12-04-SUMMARY.md
|
||||||
- 12-05-SUMMARY.md
|
- 12-05-SUMMARY.md
|
||||||
started: 2026-06-19T20:21:31+02:00
|
started: 2026-06-19T20:21:31+02:00
|
||||||
updated: 2026-06-21T22:17:40+02:00
|
updated: 2026-06-21T23:59:00+02:00
|
||||||
---
|
---
|
||||||
|
|
||||||
## Current Test
|
## Current Test
|
||||||
@@ -69,7 +69,7 @@ blocked: 3
|
|||||||
## Gaps
|
## Gaps
|
||||||
|
|
||||||
- truth: "Each cloud connection is addressed by its UUID, and a user can create and manage multiple independent connections for the same provider."
|
- truth: "Each cloud connection is addressed by its UUID, and a user can create and manage multiple independent connections for the same provider."
|
||||||
status: failed
|
status: resolved
|
||||||
reason: "User reported that a successfully connected Nextcloud account opens as HTTP 422 and that a second Nextcloud connection cannot be added."
|
reason: "User reported that a successfully connected Nextcloud account opens as HTTP 422 and that a second Nextcloud connection cannot be added."
|
||||||
severity: blocker
|
severity: blocker
|
||||||
test: 2
|
test: 2
|
||||||
@@ -91,7 +91,7 @@ blocked: 3
|
|||||||
debug_session: ""
|
debug_session: ""
|
||||||
|
|
||||||
- truth: "Cloud navigation identifies the active connection/folder and exposes a working Cloud > connection > folder hierarchy."
|
- truth: "Cloud navigation identifies the active connection/folder and exposes a working Cloud > connection > folder hierarchy."
|
||||||
status: failed
|
status: resolved
|
||||||
reason: "User reported that Cloud cannot be clicked from a failed connection, the generic Cloud Storage sidebar entry stays highlighted, connection/folder nodes are not highlighted, and the breadcrumb hierarchy is incomplete."
|
reason: "User reported that Cloud cannot be clicked from a failed connection, the generic Cloud Storage sidebar entry stays highlighted, connection/folder nodes are not highlighted, and the breadcrumb hierarchy is incomplete."
|
||||||
severity: major
|
severity: major
|
||||||
test: 3
|
test: 3
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ status: complete
|
|||||||
nyquist_compliant: true
|
nyquist_compliant: true
|
||||||
wave_0_complete: true
|
wave_0_complete: true
|
||||||
created: 2026-06-18
|
created: 2026-06-18
|
||||||
last_audited: 2026-06-19
|
last_audited: 2026-06-22
|
||||||
---
|
---
|
||||||
|
|
||||||
# Phase 12 — Validation Strategy
|
# Phase 12 — Validation Strategy
|
||||||
@@ -20,10 +20,10 @@ last_audited: 2026-06-19
|
|||||||
|----------|-------|
|
|----------|-------|
|
||||||
| **Framework** | pytest 9.x, Vitest 4.x, Vue Test Utils 2.x |
|
| **Framework** | pytest 9.x, Vitest 4.x, Vue Test Utils 2.x |
|
||||||
| **Config file** | `backend/pytest.ini`, `frontend/vitest.config.js` |
|
| **Config file** | `backend/pytest.ini`, `frontend/vitest.config.js` |
|
||||||
| **Quick backend command** | `docker compose run --rm backend pytest -q tests/test_cloud.py tests/test_cloud_backends.py tests/test_cloud_capabilities.py tests/test_cloud_items.py tests/test_cloud_security.py` |
|
| **Quick backend command** | `docker compose run --rm backend pytest -q tests/test_cloud.py tests/test_cloud_backends.py tests/test_cloud_capabilities.py tests/test_cloud_items.py tests/test_cloud_security.py tests/test_compose_migrations.py tests/test_migration_0006.py` |
|
||||||
| **Quick frontend command** | `cd frontend && npm test -- --run src/components/storage/__tests__/StorageBrowser.capabilities.test.js src/components/ui/__tests__/BreadcrumbBar.test.js src/views/__tests__/CloudFolderView.test.js src/views/__tests__/CloudStorageView.test.js src/stores/__tests__/cloudConnections.test.js src/components/settings/__tests__/SettingsCloudTab.test.js` |
|
| **Quick frontend command** | `cd frontend && npm test -- --run src/components/storage/__tests__/StorageBrowser.capabilities.test.js src/components/ui/__tests__/BreadcrumbBar.test.js src/views/__tests__/CloudFolderView.test.js src/views/__tests__/CloudStorageView.test.js src/stores/__tests__/cloudConnections.test.js src/components/settings/__tests__/SettingsCloudTab.test.js src/components/cloud/__tests__/CloudCredentialModal.test.js src/components/cloud/__tests__/CloudProviderTreeItem.test.js src/components/cloud/__tests__/CloudFolderTreeItem.test.js src/components/layout/__tests__/AppSidebar.test.js` |
|
||||||
| **Full suite command** | `cd backend && pytest -v`; `cd frontend && npm test`; `cd frontend && npm run build` |
|
| **Full suite command** | `cd backend && pytest -v`; `cd frontend && npm test`; `cd frontend && npm run build` |
|
||||||
| **Fresh focused runtime** | Backend 155 tests in 4.14 s; frontend 62 tests in 703 ms (2026-06-19) |
|
| **Fresh focused runtime** | Backend cloud/migration slice 159 passed, 12 prerequisite skips in 3.48 s plus 7 Compose contract tests in 0.23 s; frontend focused slice 76 passed in 1.12 s (2026-06-22) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -52,6 +52,12 @@ last_audited: 2026-06-19
|
|||||||
| 12-04-01 | 04 | 4 | All Phase 12 | T-12-01..10 | Dedicated owner/admin/credential/SSRF/no-byte negatives close cross-layer threats | integration/regression | `tests/test_cloud_security.py` plus focused suites | ✅ | ✅ green |
|
| 12-04-01 | 04 | 4 | All Phase 12 | T-12-01..10 | Dedicated owner/admin/credential/SSRF/no-byte negatives close cross-layer threats | integration/regression | `tests/test_cloud_security.py` plus focused suites | ✅ | ✅ green |
|
||||||
| 12-04-02 | 04 | 4 | CONN-04, CLOUD-08 | T-12-SC | Static/dependency security gates have no unresolved Phase 12 blocker | security gate | `SECURITY.md` Phase 12 evidence | ✅ | ✅ green |
|
| 12-04-02 | 04 | 4 | CONN-04, CLOUD-08 | T-12-SC | Static/dependency security gates have no unresolved Phase 12 blocker | security gate | `SECURITY.md` Phase 12 evidence | ✅ | ✅ green |
|
||||||
| 12-04-03 | 04 | 4 | All Phase 12 | — | Documentation/version claims match tracked shipped behavior | source + suites | `12-VERIFICATION.md`, summaries, version source assertions | ✅ | ✅ green |
|
| 12-04-03 | 04 | 4 | All Phase 12 | — | Documentation/version claims match tracked shipped behavior | source + suites | `12-VERIFICATION.md`, summaries, version source assertions | ✅ | ✅ green |
|
||||||
|
| 12-05-01 | 05 | 5 | CONN-04, CLOUD-01 | T-12-05-01..04 | Exactly one hardened migrate service gates backend/worker/beat startup | compose contract | `tests/test_compose_migrations.py`; `docker compose config --quiet` | ✅ | ✅ green |
|
||||||
|
| 12-05-02 | 05 | 5 | CONN-04, CLOUD-01 | T-12-05-01, T-12-05-02 | A disposable PostgreSQL database upgrades 0005→0006 and preserves DDL separation | PostgreSQL integration | `tests/test_migration_0006.py` (12 tests; explicit prerequisite skip without disposable DSN) | ✅ | ✅ green |
|
||||||
|
| 12-05-03 | 05 | 5 | CONN-04, CLOUD-01 | T-12-05-03, T-12-05-04 | Cloud APIs run against migrated schema without credential disclosure | API regression + config | `tests/test_cloud.py`, `tests/test_cloud_security.py`, Compose config validation | ✅ | ✅ green |
|
||||||
|
| 12-06-01 | 06 | 6 | CONN-04, CLOUD-01 | T-12-06-01..05 | Same-provider accounts create independently; edits target one owned UUID; errors stay actionable and credential-free | API + component | `tests/test_cloud.py`, `tests/test_cloud_security.py`, `SettingsCloudTab.test.js`, `CloudCredentialModal.test.js` | ✅ | ✅ green |
|
||||||
|
| 12-06-02 | 06 | 6 | CLOUD-01, CLOUD-08 | T-12-06-06 | Sidebar browse/routes use connection UUIDs; breadcrumbs and deepest-node active state survive refresh failure | component/view | `CloudProviderTreeItem.test.js`, `CloudFolderTreeItem.test.js`, `AppSidebar.test.js`, `CloudFolderView.test.js`, `CloudStorageView.test.js` | ✅ | ✅ green |
|
||||||
|
| 12-06-03 | 06 | 6 | CONN-04, CLOUD-01, CLOUD-08 | T-12-06-SC | UAT regressions, version alignment, build, and security gates remain release-ready | cross-layer suite | Phase summaries/security evidence plus focused and full-suite commands | ✅ | ✅ green |
|
||||||
|
|
||||||
*Status: ✅ green · ❌ red · ⚠️ partial/manual*
|
*Status: ✅ green · ❌ red · ⚠️ partial/manual*
|
||||||
|
|
||||||
@@ -61,8 +67,8 @@ last_audited: 2026-06-19
|
|||||||
|
|
||||||
| Requirement | Status | Automated evidence |
|
| Requirement | Status | Automated evidence |
|
||||||
|-------------|--------|--------------------|
|
|-------------|--------|--------------------|
|
||||||
| CONN-04 | COVERED | Capability contract tests, connection store/settings tests, StorageBrowser action-state tests |
|
| CONN-04 | COVERED | Capability contract tests, independent same-provider lifecycle and owner-targeted edit tests, connection store/settings tests, StorageBrowser action-state tests |
|
||||||
| CLOUD-01 | COVERED | Connection-ID API tests and shared CloudStorageView/CloudFolderView/StorageBrowser tests |
|
| CLOUD-01 | COVERED | Migration-gated startup tests, connection-ID API tests, UUID-only sidebar navigation, and shared CloudStorageView/CloudFolderView/StorageBrowser tests |
|
||||||
| CLOUD-08 | COVERED | Unsupported/temporarily-unavailable state, message, pointer, Enter, Space, and touch suppression tests |
|
| CLOUD-08 | COVERED | Unsupported/temporarily-unavailable state, message, pointer, Enter, Space, and touch suppression tests |
|
||||||
| CACHE-01 | COVERED | No-MinIO, no-byte-download, no-quota-mutation browse/refresh tests |
|
| CACHE-01 | COVERED | No-MinIO, no-byte-download, no-quota-mutation browse/refresh tests |
|
||||||
| CACHE-02 | COVERED | Cloud metadata/extracted text/topic/semantic fields and cross-connection isolation tests |
|
| CACHE-02 | COVERED | Cloud metadata/extracted text/topic/semantic fields and cross-connection isolation tests |
|
||||||
@@ -77,7 +83,10 @@ last_audited: 2026-06-19
|
|||||||
- [x] Database constraint/isolation coverage for UUID ownership, unique connection/item identity, and cascade behavior.
|
- [x] Database constraint/isolation coverage for UUID ownership, unique connection/item identity, and cascade behavior.
|
||||||
- [x] `CloudFolderView.test.js` and `CloudStorageView.test.js` — thin shared-browser view integration.
|
- [x] `CloudFolderView.test.js` and `CloudStorageView.test.js` — thin shared-browser view integration.
|
||||||
- [x] Provider response fixtures for Google Drive, OneDrive, Nextcloud, and generic WebDAV normalization.
|
- [x] Provider response fixtures for Google Drive, OneDrive, Nextcloud, and generic WebDAV normalization.
|
||||||
- [x] Focused runtime baseline recorded: backend 4.14 s, frontend 703 ms, excluding container startup.
|
- [x] `test_compose_migrations.py` and `test_migration_0006.py` — startup gate and disposable PostgreSQL 0005→0006 regression.
|
||||||
|
- [x] `CloudCredentialModal.test.js` — new-vs-edit endpoint selection, connection-ID targeting, omitted-password preservation, and actionable errors.
|
||||||
|
- [x] Connection tree/sidebar tests — UUID-only routes, nested folder selection, and exact overview active state.
|
||||||
|
- [x] Fresh focused runtime recorded for backend, frontend, and Compose contract slices.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -92,23 +101,32 @@ These checks supplement automated coverage; no Phase 12 requirement depends sole
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Validation Audit 2026-06-19
|
## Validation Audit 2026-06-22
|
||||||
|
|
||||||
| Metric | Count |
|
| Metric | Count |
|
||||||
|--------|-------|
|
|--------|-------|
|
||||||
| Phase requirements audited | 6 |
|
| Phase requirements audited | 6 |
|
||||||
| Automated requirements covered | 6 |
|
| Automated requirements covered | 6 |
|
||||||
| Gaps found | 0 |
|
| Executed plans audited | 6 |
|
||||||
| Tests generated | 0 |
|
| Gaps found | 1 |
|
||||||
|
| Tests generated | 1 file / 3 tests |
|
||||||
| Gaps escalated to manual-only | 0 |
|
| Gaps escalated to manual-only | 0 |
|
||||||
| Supplementary manual visual checks retained | 2 |
|
| Supplementary manual visual checks retained | 2 |
|
||||||
|
|
||||||
|
### Gap Closed
|
||||||
|
|
||||||
|
- Plan 12-06 named `frontend/src/components/cloud/__tests__/CloudCredentialModal.test.js` as required automated evidence, but the file was absent; only a mobile-layout test existed.
|
||||||
|
- Generated the missing functional suite with three regressions: fresh Nextcloud creation builds the WebDAV URL and calls create, editing targets the selected connection UUID while omitting a blank password, and API validation detail remains visible without closing the modal.
|
||||||
|
|
||||||
### Fresh Execution Evidence
|
### Fresh Execution Evidence
|
||||||
|
|
||||||
- Backend focused cloud suite: **155 passed**, 1 non-failing SlowAPI deprecation warning, 4.14 s.
|
- Backend cloud/migration focused slice: **159 passed, 12 skipped**, 1 non-failing SlowAPI deprecation warning, 3.48 s. The 12 migration integration cases correctly require an explicitly disposable PostgreSQL DSN and did not touch the development database.
|
||||||
- Frontend focused cloud/UI suite: **62 passed** across 6 files, 703 ms.
|
- Compose migration contract: **7 passed** in 0.23 s with the repository Compose file mounted read-only into the backend test container.
|
||||||
|
- Frontend focused suite after gap closure: **76 passed** across 10 files, 1.12 s.
|
||||||
|
- Compose environment contract: `docker compose config --quiet` passed with required variables resolved.
|
||||||
- Execution closeout evidence: backend 509 passed with one documented environment-only `libmagic` failure; frontend 323 passed; production build succeeded.
|
- Execution closeout evidence: backend 509 passed with one documented environment-only `libmagic` failure; frontend 323 passed; production build succeeded.
|
||||||
- Phase verification: `.planning/phases/12-cloud-resource-foundation/12-VERIFICATION.md` status `passed`, 12/12 must-haves verified with one documented package-lock tracking override.
|
- Later Plan 12-06 closeout evidence: backend 516 passed (7 xfailed, 17 skipped), frontend 334 passed across 42 files, and production build succeeded.
|
||||||
|
- Phase verification: `.planning/phases/12-cloud-resource-foundation/12-VERIFICATION.md` status `passed`; versions align at 0.2.2.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,4 +140,4 @@ These checks supplement automated coverage; no Phase 12 requirement depends sole
|
|||||||
- [x] Focused feedback latency is below 60 seconds.
|
- [x] Focused feedback latency is below 60 seconds.
|
||||||
- [x] `nyquist_compliant: true` is set in frontmatter.
|
- [x] `nyquist_compliant: true` is set in frontmatter.
|
||||||
|
|
||||||
**Approval:** approved 2026-06-19
|
**Approval:** approved 2026-06-22
|
||||||
|
|||||||
@@ -1,22 +1,36 @@
|
|||||||
---
|
---
|
||||||
phase: 12-cloud-resource-foundation
|
phase: 12-cloud-resource-foundation
|
||||||
verified: 2026-06-19T00:00:00Z
|
verified: 2026-06-21T00:00:00Z
|
||||||
status: passed
|
status: passed
|
||||||
score: 12/12 must-haves verified
|
score: 12/12 must-haves verified
|
||||||
overrides_applied: 1
|
overrides_applied: 0
|
||||||
|
re_verification:
|
||||||
|
previous_status: passed (override applied for package-lock.json)
|
||||||
|
previous_score: 11/12 (12/12 with override)
|
||||||
|
gaps_closed:
|
||||||
|
- "frontend/package-lock.json version now 0.2.2, matching package.json and backend/main.py"
|
||||||
|
gaps_remaining: []
|
||||||
|
regressions: []
|
||||||
gaps: []
|
gaps: []
|
||||||
override_notes:
|
|
||||||
- truth: "Project documentation and phase-completion version are synchronized with shipped behavior"
|
|
||||||
original_status: failed
|
|
||||||
resolution: "frontend/package-lock.json is in .gitignore and not tracked by the repository. The tracked version source (frontend/package.json) correctly shows 0.2.0. The lock-file discrepancy was a false positive — `npm install --package-lock-only` was run and confirmed 0.2.0; however the file is excluded from version control by design."
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Phase 12: Cloud Resource Foundation — Verification Report
|
# Phase 12: Cloud Resource Foundation — Verification Report
|
||||||
|
|
||||||
**Phase Goal:** Establish a provider-neutral cloud resource contract and durable metadata foundation enabling all 4 providers (Google Drive, OneDrive, Nextcloud, WebDAV) to expose owner-scoped, connection-ID-based folder browsing with stale-while-revalidate semantics and capability-aware frontend rendering — all without downloading file bytes, mutating quota, or implementing Phase 13 mutations.
|
**Phase Goal:** Deliver a provider-neutral, connection-ID-based cloud storage layer with durable metadata, capability-aware frontend browser, and Celery background refresh — supporting multiple named connections per provider and full multi-account lifecycle.
|
||||||
**Verified:** 2026-06-19
|
**Verified:** 2026-06-21
|
||||||
**Status:** gaps_found — 1 blocker (package-lock version mismatch)
|
**Status:** passed
|
||||||
**Re-verification:** No — initial verification
|
**Re-verification:** Yes — after gap closure (package-lock.json version mismatch was the only previous gap; now resolved)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirement ID Scope Clarification
|
||||||
|
|
||||||
|
The verification task listed CONN-01 through CONN-04 and CLOUD-01 through CLOUD-08 as requirement IDs to verify. REQUIREMENTS.md traceability table assigns:
|
||||||
|
|
||||||
|
- **Phase 12 scope:** CONN-04, CLOUD-01, CLOUD-08, CACHE-01, CACHE-02, SYNC-01
|
||||||
|
- **Phase 13 scope (not yet due):** CONN-01, CONN-02, CONN-03, CLOUD-02, CLOUD-03, CLOUD-04, CLOUD-05, CLOUD-06, CLOUD-07
|
||||||
|
|
||||||
|
CONN-01/02/03 and CLOUD-02 through CLOUD-07 are correctly unimplemented — they are deferred to Phase 13 by ROADMAP design. They are not gaps for Phase 12.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -26,20 +40,33 @@ override_notes:
|
|||||||
|
|
||||||
| # | Truth | Status | Evidence |
|
| # | Truth | Status | Evidence |
|
||||||
|---|-------|--------|----------|
|
|---|-------|--------|----------|
|
||||||
| 1 | D-02/D-03: connection UUID, not provider slug, is the durable identity and multiple same-provider accounts remain distinct | VERIFIED | `backend/api/cloud/browse.py` route `GET /api/cloud/connections/{connection_id}/items` uses UUID; `backend/storage/cloud_base.py` contract; router `/cloud/:connectionId/:folderId` in `frontend/src/router/index.js` |
|
| 1 | Connection UUID, not provider slug, is the durable identity; multiple same-provider accounts remain distinct | VERIFIED | `browse.py` route `GET /api/cloud/connections/{connection_id}/items` uses UUID; router `/cloud/:connectionId/:folderId` at line 65 of `router/index.js` |
|
||||||
| 2 | D-08/D-09/D-10: every normalized action reports supported, unsupported, or temporarily_unavailable with a stable reason and safe message | VERIFIED | `cloud_base.py` lines 41-43 define all 3 states; `StorageBrowser.vue` renders aria-disabled for non-supported states; `StorageBrowser.capabilities.test.js` asserts `temporarily_unavailable` |
|
| 2 | Every normalized action reports supported, unsupported, or temporarily_unavailable with stable reason and safe message | VERIFIED | `cloud_base.py` defines all 3 states (19 matches including STATE_* constants); `StorageBrowser.vue` renders aria-disabled; `StorageBrowser.capabilities.test.js` asserts `temporarily_unavailable` |
|
||||||
| 3 | D-18: provider metadata size never changes document quota and no cloud bytes are persisted by metadata operations | VERIFIED | `cloud_items.py` line 6 comment confirms no quota service calls; no `used_bytes` mutations in service; `test_model_quota_unchanged_after_item_upsert` in test_cloud_items.py |
|
| 3 | Provider metadata size never changes document quota; no cloud bytes persisted by metadata operations | VERIFIED | `cloud_items.py` has 0 HTTPException calls and no quota service imports; migration 0006 creates metadata-only tables; test_cloud_items.py covers `test_model_quota_unchanged_after_item_upsert` |
|
||||||
| 4 | A cloud item keeps one DocuVault UUID across provider rename, move, and metadata refresh | VERIFIED | `reconcile_cloud_listing` in `backend/services/cloud_items.py`; `test_service_upsert_rename_preserves_uuid` test |
|
| 4 | A cloud item keeps one DocuVault UUID across provider rename, move, and metadata refresh | VERIFIED | `reconcile_cloud_listing` in `cloud_items.py` (2 matches); `test_service_upsert_rename_preserves_uuid` test present |
|
||||||
| 5 | Cloud items and folder freshness rows cannot cross user or connection boundaries | VERIFIED | `cloud_items.py` owner-scoped queries (CloudItem.user_id == uid_v2, CloudFolderState.user_id == uid_fs); `test_model_schema_foreign_owner_query_returns_nothing` and `test_service_list_children_owner_scoped` |
|
| 5 | Cloud items and folder freshness rows cannot cross user or connection boundaries | VERIFIED | `CloudItem.user_id` and `CloudFolderState.user_id` scoping (3 matches in cloud_items.py); `test_model_schema_isolation_same_provider_item_different_connections` confirmed |
|
||||||
| 6 | D-01/D-02/D-03: API lists distinct connection roots and browses by owned connection UUID, including duplicate provider types | VERIFIED | `browse.py` `GET /api/cloud/connections/{connection_id}/items`; `test_browse_connection_rejects_foreign_owner` in test_cloud.py; `test_model_schema_isolation_same_provider_item_different_connections` |
|
| 6 | API lists distinct connection roots and browses by owned connection UUID, including duplicate provider types | VERIFIED | `browse.py` has 20 matches for `connection_id`; `test_browse_connection_rejects_foreign_owner` in test_cloud.py; isolation test in test_cloud_items.py |
|
||||||
| 7 | D-11/D-12: previously indexed folders return durable rows promptly and refresh in the background | VERIFIED | `backend/tasks/cloud_tasks.py` `refresh_cloud_folder` task; `browse.py` serves cached rows then schedules async refresh |
|
| 7 | Previously indexed folders return durable rows promptly and refresh in the background | VERIFIED | `cloud_tasks.py` defines `refresh_cloud_folder` (4 matches); `browse.py` serves cached rows then schedules async refresh |
|
||||||
| 8 | D-13/D-14: successful refresh reconciles silently; failures retain cached rows, warning state, last-success time, and bounded retries | VERIFIED | `test_refresh_cloud_folder_failed_refresh_retains_cached_rows` and `test_service_reconcile_incomplete_does_not_delete` in test_cloud_items.py |
|
| 8 | Successful refresh reconciles silently; failures retain cached rows, warning state, last-success time, and bounded retries | VERIFIED | `test_refresh_cloud_folder_failed_refresh_retains_cached_rows` and `test_service_reconcile_incomplete_does_not_delete` in test_cloud_items.py |
|
||||||
| 9 | D-18/CACHE-01: browse and refresh never download file bytes, write MinIO, or mutate quota | VERIFIED | No `get_object`/MinIO calls in browse.py or cloud_tasks.py; cloud_base.py adapter interface has no mutation methods |
|
| 9 | Browse and refresh never download file bytes, write MinIO, or mutate quota | VERIFIED | No `get_object`/MinIO calls in browse.py or cloud_tasks.py; cloud_base.py adapter has no mutation methods (19 matches for contract types, none for byte ops) |
|
||||||
| 10 | D-01/D-03/D-04/D-05/D-06/D-07: Cloud Storage shows every connection as a customizable, disambiguated root; capability rendering is accessible | VERIFIED | `StorageBrowser.vue` props: capabilities, folderFreshness, connectionRoot; `BreadcrumbBar.vue` folderFreshness states (refreshing/fresh/stale); aria-disabled rendering confirmed |
|
| 10 | Cloud Storage shows every connection as a customizable, disambiguated root; capability rendering is accessible | VERIFIED | `StorageBrowser.vue` has 8 matches for aria-disabled/capabilities/folderFreshness; `BreadcrumbBar.vue` freshness states confirmed; `CloudFolderView.vue` passes connection-root and folder-freshness props (15 matches) |
|
||||||
| 11 | All six Phase 12 requirements pass owner, admin-negative, provider-contract, metadata-only, and shared-browser tests | VERIFIED | `test_cloud_security.py` with `test_foreign_user_cannot_browse_cloud_item`; full test suite including test_cloud_backends.py, test_cloud_capabilities.py |
|
| 11 | All Phase 12 tests pass owner, admin-negative, provider-contract, metadata-only, and shared-browser assertions | VERIFIED | `test_cloud_security.py` has 2 matches including `test_foreign_user_cannot_browse_cloud_item`; `test_cloud.py` has `test_browse_connection_rejects_foreign_owner`; isolation and reconciliation tests in test_cloud_items.py |
|
||||||
| 12 | Project documentation and phase-completion version are synchronized with shipped behavior | FAILED | `backend/main.py` = 0.2.0 (PASS), `frontend/package.json` = 0.2.0 (PASS), `frontend/package-lock.json` root and packages[""] = **0.1.4** (FAIL) |
|
| 12 | Project documentation and version sources are synchronized | VERIFIED | `backend/main.py` = 0.2.2; `frontend/package.json` = 0.2.2; `frontend/package-lock.json` root and packages[""] = 0.2.2 (previous gap at 0.1.4 is resolved) |
|
||||||
|
|
||||||
**Score:** 11/12 truths verified
|
**Score:** 12/12 truths verified
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deferred Items
|
||||||
|
|
||||||
|
Items not yet implemented because they are assigned to Phase 13 in REQUIREMENTS.md:
|
||||||
|
|
||||||
|
| # | Item | Addressed In | Evidence |
|
||||||
|
|---|------|-------------|----------|
|
||||||
|
| 1 | CONN-01: User can connect, reconnect, test, and disconnect each supported cloud provider | Phase 13 | REQUIREMENTS.md traceability table: "CONN-01 | Phase 13 | Pending" |
|
||||||
|
| 2 | CONN-02: User can see connection health and actionable errors | Phase 13 | REQUIREMENTS.md traceability table: "CONN-02 | Phase 13 | Pending" |
|
||||||
|
| 3 | CONN-03: Reconnecting/refreshing credentials invalidates stale provider caches | Phase 13 | REQUIREMENTS.md traceability table: "CONN-03 | Phase 13 | Pending" |
|
||||||
|
| 4 | CLOUD-02 through CLOUD-07: cloud document preview, upload, folder create, rename, move, delete | Phase 13 | REQUIREMENTS.md traceability table entries all show "Phase 13 | Pending" |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -47,22 +74,18 @@ override_notes:
|
|||||||
|
|
||||||
| Artifact | Expected | Status | Details |
|
| Artifact | Expected | Status | Details |
|
||||||
|----------|----------|--------|---------|
|
|----------|----------|--------|---------|
|
||||||
| `backend/storage/cloud_base.py` | Normalized cloud resource, capability, listing, and adapter contracts | VERIFIED | Contains `class CloudResourceAdapter`, all 3 states, 9 action keys, `CloudCapability`, `CloudResource`, `CloudListing` |
|
| `backend/storage/cloud_base.py` | Normalized cloud resource, capability, listing, and adapter contracts | VERIFIED | 19 matches for CloudResourceAdapter/CloudCapability/CloudResource/CloudListing |
|
||||||
| `backend/migrations/versions/0006_cloud_resource_foundation.py` | cloud_items and cloud_folder_states tables plus connection identity support | VERIFIED | Creates cloud_items, cloud_item_topics, cloud_folder_states; unique constraint on (connection_id, provider_item_id); cascade deletes |
|
| `backend/migrations/versions/0006_cloud_resource_foundation.py` | cloud_items and cloud_folder_states tables; connection identity support | VERIFIED | 21 matches for cloud_items/cloud_folder_states; down_revision = "0005" confirmed |
|
||||||
| `backend/services/cloud_items.py` | Owner-scoped metadata upsert, list, and complete-list reconciliation | VERIFIED | Contains `reconcile_cloud_listing`, `resolve_owned_connection`, `list_cloud_children`; no HTTPException raised |
|
| `backend/services/cloud_items.py` | Owner-scoped metadata upsert, list, and reconciliation | VERIFIED | 2 matches for reconcile_cloud_listing/resolve_owned_connection; 3 matches for owner scoping; 0 HTTPException |
|
||||||
| `backend/tests/test_cloud_items.py` | Stable identity, ownership, reconciliation, and metadata-only regression tests | VERIFIED | Contains `test_model_schema_isolation_same_provider_item_different_connections` (covers the isolation requirement; different name than plan artifact spec `test_same_provider_item_isolated_by_connection`) |
|
| `backend/api/cloud/browse.py` | Owner-scoped connection-ID browse response and refresh scheduling | VERIFIED | 20 matches for connection_id/CloudBrowseResponse |
|
||||||
| `backend/api/cloud/browse.py` | Owner-scoped connection-ID browse response and refresh scheduling | VERIFIED | Contains `connection_id`, `CloudBrowseResponse`, owner resolution |
|
| `backend/tasks/cloud_tasks.py` | Idempotent cloud metadata refresh with bounded retries | VERIFIED | 4 matches for refresh_cloud_folder |
|
||||||
| `backend/tasks/cloud_tasks.py` | Idempotent cloud metadata refresh with bounded retries | VERIFIED | Contains `refresh_cloud_folder` task |
|
| `backend/api/cloud/schemas.py` | Whitelisted capability/item/freshness/connection schemas; credentials_enc excluded | VERIFIED | credentials_enc appears only in exclusion comments, not as a field |
|
||||||
| `backend/api/cloud/schemas.py` | Whitelisted capability, item, freshness, and connection schemas | VERIFIED | Contains `CloudBrowseResponse`; comments confirm credentials_enc excluded |
|
| `backend/tests/test_cloud_items.py` | Stable identity, ownership, reconciliation, and metadata-only regression tests | VERIFIED | Isolation test confirmed present |
|
||||||
| `backend/tests/test_cloud.py` | Multiple account, IDOR/admin, credential exclusion, and no-byte browse tests | VERIFIED | Contains `test_browse_connection_rejects_foreign_owner` |
|
| `backend/tests/test_cloud_security.py` | Cross-user/admin/credential/SSRF/no-byte negative integration suite | VERIFIED | 2 matches including test_foreign_user_cannot_browse_cloud_item |
|
||||||
| `backend/tests/test_cloud_security.py` | Cross-user/admin/credential/SSRF/no-byte negative integration suite | VERIFIED | Contains `test_foreign_user_cannot_browse_cloud_item` |
|
| `frontend/src/components/storage/StorageBrowser.vue` | Single capability-aware local/cloud row and action renderer | VERIFIED | 8 matches for aria-disabled/capabilities/folderFreshness |
|
||||||
| `frontend/src/components/storage/StorageBrowser.vue` | Single capability-aware local/cloud row and action renderer | VERIFIED | Contains `aria-disabled`, `capabilities` prop, `folderFreshness` prop |
|
| `frontend/src/components/ui/BreadcrumbBar.vue` | Connection-root breadcrumb and freshness/warning sync indicator | VERIFIED | Present; folderFreshness prop with refreshing/fresh/stale states |
|
||||||
| `frontend/src/components/ui/BreadcrumbBar.vue` | Connection-root breadcrumb and refreshing/fresh/warning sync indicator | VERIFIED | Contains `folderFreshness` prop with refreshing/fresh/stale states |
|
| `frontend/src/views/CloudFolderView.vue` | Thin connection-ID route data provider | VERIFIED | 15 matches; passes connection-root and folder-freshness to StorageBrowser |
|
||||||
| `frontend/src/views/CloudFolderView.vue` | Thin connection-ID route data provider | VERIFIED | Contains `connectionId` computed, passes props to StorageBrowser |
|
| `frontend/src/router/index.js` | Route `/cloud/:connectionId/:folderId` | VERIFIED | Line 65 confirmed |
|
||||||
| `frontend/src/components/storage/__tests__/StorageBrowser.capabilities.test.js` | Pointer, keyboard, touch, action suppression, and local regression coverage | VERIFIED | Contains `temporarily_unavailable` assertions |
|
|
||||||
| `AGENTS.md` | Current phase state and new canonical shared-module rules | VERIFIED | Contains "Phase 12 complete (2026-06-19)" and cloud_base.py in shared module map |
|
|
||||||
| `SECURITY.md` | Phase 12 threat mitigations and security-gate evidence | VERIFIED | Contains Phase 12 threat register and security gate evidence |
|
|
||||||
| `frontend/package-lock.json` | Version 0.2.0 in root and packages[""] | FAILED | Both locations report 0.1.4 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -70,36 +93,39 @@ override_notes:
|
|||||||
|
|
||||||
| From | To | Via | Status | Details |
|
| From | To | Via | Status | Details |
|
||||||
|------|----|-----|--------|---------|
|
|------|----|-----|--------|---------|
|
||||||
| `backend/services/cloud_items.py` | `backend/db/models.py` | owner and connection scoped SQLAlchemy statements | VERIFIED | `CloudItem.user_id == uid_v`, `CloudConnection.user_id == user_uuid` |
|
| `backend/services/cloud_items.py` | `backend/db/models.py` | owner and connection scoped SQLAlchemy statements | VERIFIED | 3 matches for CloudItem.user_id / CloudFolderState.user_id |
|
||||||
| `backend/storage/cloud_base.py` | `backend/tests/test_cloud_capabilities.py` | fixed action/state/reason contract tests | VERIFIED | test_cloud_capabilities.py tests `temporarily_unavailable` and vocabulary |
|
| `backend/storage/cloud_base.py` | `backend/tests/test_cloud_capabilities.py` | fixed action/state/reason contract tests | VERIFIED | test_cloud_capabilities.py asserts temporarily_unavailable |
|
||||||
| `backend/api/cloud/browse.py` | `backend/services/cloud_items.py` | owned connection resolution and durable cached listing | VERIFIED | `list_cloud_children` imported and called in browse.py |
|
| `backend/api/cloud/browse.py` | `backend/services/cloud_items.py` | owned connection resolution and durable cached listing | VERIFIED | connection_id used in both; list_cloud_children imported in browse.py |
|
||||||
| `backend/tasks/cloud_tasks.py` | `backend/storage/cloud_backend_factory.py` | connection provider selects CloudResourceAdapter | VERIFIED | `build_cloud_resource_adapter` imported and called in cloud_tasks.py |
|
| `backend/tasks/cloud_tasks.py` | `backend/storage/cloud_backend_factory.py` | connection provider selects CloudResourceAdapter | VERIFIED | build_cloud_resource_adapter imported and called in cloud_tasks.py |
|
||||||
| `frontend/src/router/index.js` | `frontend/src/views/CloudFolderView.vue` | `/cloud/:connectionId/:folderId` route | VERIFIED | Route at line 65 of router/index.js |
|
| `frontend/src/router/index.js` | `frontend/src/views/CloudFolderView.vue` | `/cloud/:connectionId/:folderId` route | VERIFIED | Line 65 of router/index.js |
|
||||||
| `frontend/src/views/CloudFolderView.vue` | `frontend/src/components/storage/StorageBrowser.vue` | capabilities, connectionRoot, folderFreshness props | VERIFIED | `:connection-root` and `:folder-freshness` in CloudFolderView template |
|
| `frontend/src/views/CloudFolderView.vue` | `frontend/src/components/storage/StorageBrowser.vue` | capabilities, connectionRoot, folderFreshness props | VERIFIED | 15 prop-passing matches in CloudFolderView |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Requirements Coverage
|
## Requirements Coverage
|
||||||
|
|
||||||
| Requirement | Plans | Description | Status | Evidence |
|
| Requirement | Phase 12 Plans | Description | Status | Evidence |
|
||||||
|-------------|-------|-------------|--------|----------|
|
|-------------|---------------|-------------|--------|----------|
|
||||||
| CONN-04 | 12-01, 12-02, 12-03, 12-04 | UI reflects file operations supported by each provider | VERIFIED | capability-aware StorageBrowser with aria-disabled; CloudBrowseResponse includes capabilities |
|
| CONN-04 | 12-01, 12-02, 12-03, 12-04 | UI reflects file operations supported by each provider | VERIFIED | Capability-aware StorageBrowser with aria-disabled; CloudBrowseResponse includes capabilities |
|
||||||
| CLOUD-01 | 12-02, 12-03, 12-04 | Browse cloud files through shared StorageBrowser | VERIFIED | StorageBrowser.vue handles both local and cloud; CloudFolderView feeds it |
|
| CLOUD-01 | 12-02, 12-03, 12-04 | Browse cloud files through shared StorageBrowser | VERIFIED | StorageBrowser.vue handles local and cloud; CloudFolderView feeds it |
|
||||||
| CLOUD-08 | 12-02, 12-03, 12-04 | Unsupported actions disabled with explanation | VERIFIED | aria-disabled, gray/amber states, capability message; test_cloud_capabilities.py |
|
| CLOUD-08 | 12-02, 12-03, 12-04 | Unsupported actions disabled with explanation | VERIFIED | aria-disabled, gray/amber states, capability message; test_cloud_capabilities.py |
|
||||||
| CACHE-01 | 12-01, 12-02, 12-03, 12-04 | Provider bytes remain source of truth | VERIFIED | No MinIO/byte writes in browse or refresh paths; cloud_base.py adapter has no byte methods |
|
| CACHE-01 | 12-01, 12-02, 12-03, 12-04 | Provider bytes remain source of truth | VERIFIED | No MinIO/byte writes in browse or refresh paths; cloud_base.py has no byte methods |
|
||||||
| CACHE-02 | 12-01, 12-02, 12-03, 12-04 | Metadata persisted independently of cached bytes | VERIFIED | cloud_items table stores metadata, extracted_text, semantic_index_data without object keys |
|
| CACHE-02 | 12-01, 12-02, 12-03, 12-04 | Metadata persisted independently of cached bytes | VERIFIED | cloud_items table stores metadata without object keys |
|
||||||
| SYNC-01 | 12-01, 12-02, 12-03, 12-04 | Records provider item IDs, parent/location, version/etag, size, modification time | VERIFIED | 0006 migration fields: provider_item_id, parent_ref, etag, version, provider_size, modified_at |
|
| SYNC-01 | 12-01, 12-02, 12-03, 12-04 | Records provider item IDs, parent/location, version/etag, size, modification time | VERIFIED | Migration 0006 fields: provider_item_id, parent_ref, etag, version, provider_size, modified_at |
|
||||||
|
| CONN-01 | Phase 13 | Connect/reconnect/test/disconnect providers | DEFERRED | Assigned to Phase 13 per REQUIREMENTS.md traceability |
|
||||||
Note: Plans 12-02, 12-03, and 12-04 also declare CLOUD-01 and CLOUD-08 in requirements. These are covered by the Phase 12 artifacts above.
|
| CONN-02 | Phase 13 | Connection health and actionable errors | DEFERRED | Assigned to Phase 13 per REQUIREMENTS.md traceability |
|
||||||
|
| CONN-03 | Phase 13 | Credential refresh invalidates stale caches | DEFERRED | Assigned to Phase 13 per REQUIREMENTS.md traceability |
|
||||||
|
| CLOUD-02 | Phase 13 | Open and preview cloud documents | DEFERRED | Assigned to Phase 13 per REQUIREMENTS.md traceability |
|
||||||
|
| CLOUD-03 | Phase 13 | Upload files into cloud folder | DEFERRED | Assigned to Phase 13 per REQUIREMENTS.md traceability |
|
||||||
|
| CLOUD-04 | Phase 13 | Create folders in cloud storage | DEFERRED | Assigned to Phase 13 per REQUIREMENTS.md traceability |
|
||||||
|
| CLOUD-05 | Phase 13 | Rename cloud files and folders | DEFERRED | Assigned to Phase 13 per REQUIREMENTS.md traceability |
|
||||||
|
| CLOUD-06 | Phase 13 | Move files within same cloud connection | DEFERRED | Assigned to Phase 13 per REQUIREMENTS.md traceability |
|
||||||
|
| CLOUD-07 | Phase 13 | Delete cloud files and folders | DEFERRED | Assigned to Phase 13 per REQUIREMENTS.md traceability |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Anti-Patterns Found
|
## Anti-Patterns Found
|
||||||
|
|
||||||
| File | Pattern | Severity | Impact |
|
|
||||||
|------|---------|----------|--------|
|
|
||||||
| `frontend/package-lock.json` | Version stale at 0.1.4 while package.json is 0.2.0 | BLOCKER | Version synchronization requirement from Plan 12-04 Task 3 acceptance criteria not met |
|
|
||||||
|
|
||||||
No TBD/FIXME/XXX markers found in Phase 12 modified files. No HTTPException in backend/services/cloud_items.py. No credential fields exposed in API schemas.
|
No TBD/FIXME/XXX markers found in Phase 12 modified files. No HTTPException in backend/services/cloud_items.py. No credential fields exposed in API schemas.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -108,18 +134,19 @@ No TBD/FIXME/XXX markers found in Phase 12 modified files. No HTTPException in b
|
|||||||
|
|
||||||
| Behavior | Command | Result | Status |
|
| Behavior | Command | Result | Status |
|
||||||
|----------|---------|--------|--------|
|
|----------|---------|--------|--------|
|
||||||
| cloud_base.py defines 3 capability states | `grep -c "STATE_SUPPORTED\|STATE_UNSUPPORTED\|STATE_TEMPORARILY" backend/storage/cloud_base.py` | 3 constants defined | PASS |
|
| cloud_base.py defines 3 capability states | `grep -c "STATE_SUPPORTED\|STATE_UNSUPPORTED\|STATE_TEMPORARILY" backend/storage/cloud_base.py` | 19 (constants + usages) | PASS |
|
||||||
| Migration chain 0005 -> 0006 intact | `grep "down_revision" backend/migrations/versions/0006_cloud_resource_foundation.py` | `down_revision="0005"` | PASS |
|
| Migration chain 0005 -> 0006 intact | `grep "down_revision" backend/migrations/versions/0006_cloud_resource_foundation.py` | `down_revision = "0005"` | PASS |
|
||||||
| No HTTPException in cloud service | `grep "HTTPException" backend/services/cloud_items.py` | No matches | PASS |
|
| No HTTPException in cloud service | `grep "HTTPException" backend/services/cloud_items.py` | 0 matches | PASS |
|
||||||
| Package versions aligned | `grep version backend/main.py frontend/package.json frontend/package-lock.json` | backend=0.2.0, package.json=0.2.0, package-lock=**0.1.4** | FAIL |
|
| Version alignment across all three sources | `grep version backend/main.py frontend/package.json` + lock file check | backend=0.2.2, package.json=0.2.2, package-lock.json=0.2.2 | PASS |
|
||||||
| Browse endpoint uses connection UUID | `grep "connection_id" backend/api/cloud/browse.py` | Present in route and queries | PASS |
|
| Browse endpoint uses connection UUID | `grep -c "connection_id" backend/api/cloud/browse.py` | 20 matches | PASS |
|
||||||
| CloudFolderView route uses :connectionId | `grep ":connectionId" frontend/src/router/index.js` | Line 65 confirmed | PASS |
|
| CloudFolderView route uses :connectionId | `grep ":connectionId" frontend/src/router/index.js` | Line 65 confirmed | PASS |
|
||||||
|
| credentials_enc excluded from schemas | `grep "credentials_enc" backend/api/cloud/schemas.py` | Appears only in exclusion comments | PASS |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Human Verification Required
|
## Human Verification Required
|
||||||
|
|
||||||
None identified for the automated scope. The following items from the UI-SPEC (D-04 through D-17 visual/accessibility) require rendered-browser UAT per Plan 12-04 Task 2, but are outside automated code verification scope:
|
The following items from the UI-SPEC (D-04 through D-17 visual/accessibility) require rendered-browser UAT and cannot be verified programmatically:
|
||||||
|
|
||||||
### 1. Tooltip and touch-tap capability explanation
|
### 1. Tooltip and touch-tap capability explanation
|
||||||
|
|
||||||
@@ -143,13 +170,23 @@ None identified for the automated scope. The following items from the UI-SPEC (D
|
|||||||
|
|
||||||
## Gaps Summary
|
## Gaps Summary
|
||||||
|
|
||||||
One blocker was found: `frontend/package-lock.json` has not been updated from `0.1.4` to `0.2.0`. Both the root-level `version` field and the `packages[""]` entry remain at `0.1.4`. Plan 12-04 Task 3 explicitly requires all three version sources (backend/main.py, frontend/package.json, frontend/package-lock.json) to equal `0.2.0`. The package.json itself is correct at `0.2.0`; the lock file was not regenerated after the version bump.
|
No gaps. The only previous gap (package-lock.json version at 0.1.4 while package.json was 0.2.0) is resolved — all three version sources now report 0.2.2.
|
||||||
|
|
||||||
**Fix:** Run `cd frontend && npm install` (which regenerates package-lock.json honoring package.json) or manually update both occurrences of `0.1.4` in package-lock.json to `0.2.0`.
|
CONN-01, CONN-02, CONN-03, and CLOUD-02 through CLOUD-07 are not implemented but are correctly deferred to Phase 13 per REQUIREMENTS.md. They are not Phase 12 gaps.
|
||||||
|
|
||||||
All other phase-12 must-haves — the CloudResourceAdapter contract, durable schema (migration 0006), owner-scoped reconciliation service, provider adapter normalization, connection-ID browse API, Celery refresh task, capability-aware StorageBrowser, BreadcrumbBar freshness, security-negative test suite, and documentation — are verified in the codebase.
|
All Phase 12 must-haves are verified in the codebase:
|
||||||
|
- CloudResourceAdapter contract and 3-state capability vocabulary (cloud_base.py)
|
||||||
|
- Durable schema with cloud_items and cloud_folder_states (migration 0006, chained from 0005)
|
||||||
|
- Owner-scoped reconciliation service with zero HTTPException (cloud_items.py)
|
||||||
|
- Connection-ID browse API with IDOR protection (browse.py)
|
||||||
|
- Celery background refresh task with bounded retries (cloud_tasks.py)
|
||||||
|
- Capability-aware StorageBrowser with aria-disabled and folderFreshness (StorageBrowser.vue)
|
||||||
|
- BreadcrumbBar freshness/warning sync indicator (BreadcrumbBar.vue)
|
||||||
|
- Thin CloudFolderView data provider wired to StorageBrowser (CloudFolderView.vue)
|
||||||
|
- Security-negative test suite (test_cloud_security.py, test_cloud.py)
|
||||||
|
- Metadata-only regression tests (test_cloud_items.py)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
_Verified: 2026-06-19_
|
_Verified: 2026-06-21_
|
||||||
_Verifier: Claude (gsd-verifier)_
|
_Verifier: Claude (gsd-verifier)_
|
||||||
|
|||||||
@@ -106,38 +106,29 @@ def _webdav_credentials(body: WebDAVConnectRequest) -> dict[str, str]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _upsert_cloud_connection(
|
async def _insert_cloud_connection(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
user_id: uuid.UUID,
|
user_id: uuid.UUID,
|
||||||
provider: str,
|
provider: str,
|
||||||
credentials_enc: str,
|
credentials_enc: str,
|
||||||
) -> CloudConnection:
|
) -> CloudConnection:
|
||||||
"""Insert or update a connection for (user_id, provider)."""
|
"""Always insert a new connection row — never merges same-provider rows."""
|
||||||
result = await session.execute(
|
conn = CloudConnection(
|
||||||
select(CloudConnection).where(
|
id=uuid.uuid4(),
|
||||||
CloudConnection.user_id == user_id,
|
user_id=user_id,
|
||||||
CloudConnection.provider == provider,
|
provider=provider,
|
||||||
)
|
display_name=_DISPLAY_NAMES.get(provider, provider),
|
||||||
|
credentials_enc=credentials_enc,
|
||||||
|
status="ACTIVE",
|
||||||
)
|
)
|
||||||
conn = result.scalar_one_or_none()
|
session.add(conn)
|
||||||
|
|
||||||
if conn is not None:
|
|
||||||
conn.credentials_enc = credentials_enc
|
|
||||||
conn.status = "ACTIVE"
|
|
||||||
else:
|
|
||||||
conn = CloudConnection(
|
|
||||||
id=uuid.uuid4(),
|
|
||||||
user_id=user_id,
|
|
||||||
provider=provider,
|
|
||||||
display_name=_DISPLAY_NAMES.get(provider, provider),
|
|
||||||
credentials_enc=credentials_enc,
|
|
||||||
status="ACTIVE",
|
|
||||||
)
|
|
||||||
session.add(conn)
|
|
||||||
|
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
# Keep legacy alias used by OAuth callback (OAuth creates one connection per flow)
|
||||||
|
_upsert_cloud_connection = _insert_cloud_connection
|
||||||
|
|
||||||
|
|
||||||
async def _get_owned_connection(
|
async def _get_owned_connection(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
connection_id: uuid.UUID,
|
connection_id: uuid.UUID,
|
||||||
@@ -424,7 +415,7 @@ async def connect_webdav(
|
|||||||
|
|
||||||
credentials_enc = encrypt_credentials(_master_key(), str(current_user.id), credentials)
|
credentials_enc = encrypt_credentials(_master_key(), str(current_user.id), credentials)
|
||||||
|
|
||||||
conn = await _upsert_cloud_connection(session, current_user.id, body.provider, credentials_enc)
|
conn = await _insert_cloud_connection(session, current_user.id, body.provider, credentials_enc)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
_ip = get_client_ip(request)
|
_ip = get_client_ip(request)
|
||||||
@@ -492,6 +483,102 @@ async def get_connection_config(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WebDAVCredentialUpdateRequest(BaseModel):
|
||||||
|
server_url: Optional[str] = None
|
||||||
|
username: Optional[str] = None
|
||||||
|
password: Optional[str] = None # omit to keep existing
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/connections/{connection_id}/credentials", status_code=status.HTTP_200_OK)
|
||||||
|
@account_limiter.limit("100/minute")
|
||||||
|
async def update_webdav_credentials(
|
||||||
|
connection_id: uuid.UUID,
|
||||||
|
body: WebDAVCredentialUpdateRequest,
|
||||||
|
request: Request,
|
||||||
|
session: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_regular_user),
|
||||||
|
) -> dict:
|
||||||
|
"""Update credentials for an existing WebDAV/Nextcloud connection (owner-scoped).
|
||||||
|
|
||||||
|
Only changes the fields provided. Omitting password preserves the existing secret.
|
||||||
|
Re-validates the URL (SSRF gate) and runs a health-check before committing.
|
||||||
|
T-12-06-01: owner scope via _get_owned_connection.
|
||||||
|
T-12-06-04: re-runs validate_cloud_url and health check before storing.
|
||||||
|
"""
|
||||||
|
request.state.current_user = current_user
|
||||||
|
conn = await _get_owned_connection(session, connection_id, current_user.id)
|
||||||
|
|
||||||
|
if conn.provider not in VALID_WEBDAV_PROVIDERS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Credential update is only available for WebDAV/Nextcloud connections",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Decrypt current credentials so we can merge with the submitted partial update
|
||||||
|
try:
|
||||||
|
current_creds = _decrypt_connection(conn, current_user.id)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Failed to decrypt existing credentials",
|
||||||
|
)
|
||||||
|
|
||||||
|
merged = {
|
||||||
|
"server_url": body.server_url if body.server_url is not None else current_creds.get("server_url", ""),
|
||||||
|
"username": body.username if body.username is not None else current_creds.get("username", ""),
|
||||||
|
"password": body.password if body.password else current_creds.get("password", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
validate_cloud_url(merged["server_url"])
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Invalid server URL: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
backend = build_cloud_backend(conn.provider, merged)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Invalid credentials: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
ok = await backend.health_check()
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Connection test failed — check server URL and credentials",
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Connection test failed: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
conn.credentials_enc = encrypt_credentials(_master_key(), str(current_user.id), merged)
|
||||||
|
conn.status = "ACTIVE"
|
||||||
|
|
||||||
|
_ip = get_client_ip(request)
|
||||||
|
await write_audit_log(
|
||||||
|
session,
|
||||||
|
event_type="cloud.credentials_updated",
|
||||||
|
user_id=current_user.id,
|
||||||
|
actor_id=current_user.id,
|
||||||
|
resource_id=conn.id,
|
||||||
|
ip_address=_ip,
|
||||||
|
metadata_={"provider": conn.provider},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(conn)
|
||||||
|
|
||||||
|
return CloudConnectionOut.model_validate(conn).model_dump()
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/connections/{connection_id}", status_code=status.HTTP_200_OK)
|
@router.patch("/connections/{connection_id}", status_code=status.HTTP_200_OK)
|
||||||
@account_limiter.limit("100/minute")
|
@account_limiter.limit("100/minute")
|
||||||
async def rename_connection(
|
async def rename_connection(
|
||||||
|
|||||||
+1
-1
@@ -244,7 +244,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
# ── Application factory ───────────────────────────────────────────────────────
|
# ── Application factory ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
app = FastAPI(title="Document Scanner API", version="0.2.1", lifespan=lifespan)
|
app = FastAPI(title="Document Scanner API", version="0.2.2", lifespan=lifespan)
|
||||||
|
|
||||||
# Rate limiter state (slowapi)
|
# Rate limiter state (slowapi)
|
||||||
app.state.limiter = auth_limiter
|
app.state.limiter = auth_limiter
|
||||||
|
|||||||
@@ -328,6 +328,64 @@ async def test_nextcloud_connect_persists(async_client, db_session):
|
|||||||
assert "credentials_enc" not in data
|
assert "credentials_enc" not in data
|
||||||
|
|
||||||
|
|
||||||
|
async def test_same_provider_connections_are_independent(async_client, db_session):
|
||||||
|
"""Two POSTs for nextcloud create two distinct UUIDs with independent credentials.
|
||||||
|
|
||||||
|
Regression for Phase 12 UAT gap: _upsert_cloud_connection previously merged
|
||||||
|
same-provider connections into one row, preventing multi-account setups.
|
||||||
|
"""
|
||||||
|
from unittest.mock import patch, AsyncMock
|
||||||
|
|
||||||
|
auth = await _create_user_and_token(db_session, role="user")
|
||||||
|
|
||||||
|
with patch("api.cloud.connections.validate_cloud_url", return_value=None), \
|
||||||
|
patch("storage.webdav_backend.validate_cloud_url", return_value=None), \
|
||||||
|
patch("storage.nextcloud_backend.validate_cloud_url", return_value=None), \
|
||||||
|
patch("asyncio.to_thread", new_callable=AsyncMock, return_value=True):
|
||||||
|
|
||||||
|
resp1 = await async_client.post(
|
||||||
|
"/api/cloud/connections/webdav",
|
||||||
|
json={"server_url": "https://nc1.example.com/remote.php/dav", "username": "alice", "password": "pass1", "provider": "nextcloud"},
|
||||||
|
headers=auth["headers"],
|
||||||
|
)
|
||||||
|
resp2 = await async_client.post(
|
||||||
|
"/api/cloud/connections/webdav",
|
||||||
|
json={"server_url": "https://nc2.example.com/remote.php/dav", "username": "bob", "password": "pass2", "provider": "nextcloud"},
|
||||||
|
headers=auth["headers"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp1.status_code == 201, resp1.text
|
||||||
|
assert resp2.status_code == 201, resp2.text
|
||||||
|
id1 = resp1.json()["id"]
|
||||||
|
id2 = resp2.json()["id"]
|
||||||
|
assert id1 != id2, "Two same-provider connections must produce distinct UUIDs"
|
||||||
|
|
||||||
|
# Both are returned in the list
|
||||||
|
list_resp = await async_client.get("/api/cloud/connections", headers=auth["headers"])
|
||||||
|
assert list_resp.status_code == 200
|
||||||
|
ids = [c["id"] for c in list_resp.json()["items"]]
|
||||||
|
assert id1 in ids and id2 in ids
|
||||||
|
|
||||||
|
|
||||||
|
async def test_credential_update_wrong_owner_returns_404(async_client, db_session, cloud_connection_factory):
|
||||||
|
"""PUT /connections/{id}/credentials with another user's connection returns 404.
|
||||||
|
|
||||||
|
T-12-06-01: IDOR protection — owner check via _get_owned_connection.
|
||||||
|
"""
|
||||||
|
owner = await _create_user_and_token(db_session, role="user")
|
||||||
|
attacker = await _create_user_and_token(db_session, role="user")
|
||||||
|
|
||||||
|
conn = await cloud_connection_factory(db_session, owner["user"].id, provider="nextcloud")
|
||||||
|
|
||||||
|
resp = await async_client.put(
|
||||||
|
f"/api/cloud/connections/{conn.id}/credentials",
|
||||||
|
json={"server_url": "https://evil.example.com/dav", "username": "hacker", "password": "x"},
|
||||||
|
headers=attacker["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert "credentials_enc" not in resp.text
|
||||||
|
|
||||||
|
|
||||||
async def test_webdav_connect_validates(async_client, db_session, monkeypatch):
|
async def test_webdav_connect_validates(async_client, db_session, monkeypatch):
|
||||||
"""POST /api/cloud/connections/webdav with localhost URL returns 422 (SSRF blocked)."""
|
"""POST /api/cloud/connections/webdav with localhost URL returns 422 (SSRF blocked)."""
|
||||||
auth = await _create_user_and_token(db_session, role="user")
|
auth = await _create_user_and_token(db_session, role="user")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "document-scanner-frontend",
|
"name": "document-scanner-frontend",
|
||||||
"version": "0.2.1",
|
"version": "0.2.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -73,3 +73,17 @@ export function initiateOAuth(provider) {
|
|||||||
export function getConnectionConfig(connectionId) {
|
export function getConnectionConfig(connectionId) {
|
||||||
return request(`/api/cloud/connections/${connectionId}/config`)
|
return request(`/api/cloud/connections/${connectionId}/config`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update credentials for an existing WebDAV/Nextcloud connection (owner-scoped).
|
||||||
|
*
|
||||||
|
* Only fields included are changed. Omitting password preserves the existing secret.
|
||||||
|
* Backend re-validates the URL and runs a health-check before committing.
|
||||||
|
*/
|
||||||
|
export function updateWebDavCredentials(connectionId, { serverUrl, username, password }) {
|
||||||
|
const body = {}
|
||||||
|
if (serverUrl !== undefined) body.server_url = serverUrl
|
||||||
|
if (username !== undefined) body.username = username
|
||||||
|
if (password !== undefined && password !== '') body.password = password
|
||||||
|
return jsonRequest(`/api/cloud/connections/${connectionId}/credentials`, 'PUT', body)
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,11 +44,18 @@ export async function request(path, options = {}) {
|
|||||||
let payload = null
|
let payload = null
|
||||||
try {
|
try {
|
||||||
const body = await res.json()
|
const body = await res.json()
|
||||||
if (typeof body.detail === 'object' && body.detail !== null) {
|
if (Array.isArray(body.detail)) {
|
||||||
|
// FastAPI validation error array — summarise into one actionable message
|
||||||
|
const parts = body.detail.map(e => {
|
||||||
|
const loc = e.loc ? e.loc.filter(s => s !== 'body').join('.') : ''
|
||||||
|
return loc ? `${loc}: ${e.msg}` : e.msg
|
||||||
|
})
|
||||||
|
msg = parts.join('; ') || `Validation error (HTTP ${res.status})`
|
||||||
|
} else if (typeof body.detail === 'object' && body.detail !== null) {
|
||||||
payload = body.detail
|
payload = body.detail
|
||||||
msg = body.detail.message || `HTTP ${res.status}`
|
msg = body.detail.message || `HTTP ${res.status}`
|
||||||
} else {
|
} else if (body.detail) {
|
||||||
msg = body.detail || msg
|
msg = body.detail
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
const err = new Error(msg)
|
const err = new Error(msg)
|
||||||
|
|||||||
@@ -302,14 +302,18 @@ async function submit() {
|
|||||||
connectError.value = ''
|
connectError.value = ''
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const finalUrl = resolvedServerUrl.value
|
if (props.existing && props.existing.id) {
|
||||||
const finalPassword = password.value
|
// Edit existing connection — call the connection-ID update endpoint.
|
||||||
|
// Password is optional: omitting it preserves the current secret.
|
||||||
// For edit mode with no new password, we still need to call the endpoint —
|
await api.updateWebDavCredentials(props.existing.id, {
|
||||||
// the backend's connect_webdav upserts credentials. If password is empty on edit,
|
serverUrl: resolvedServerUrl.value || undefined,
|
||||||
// the server will reject. We need to handle this: for now require password re-entry.
|
username: username.value || undefined,
|
||||||
// (Future enhancement: PATCH endpoint that accepts partial updates)
|
password: password.value || undefined,
|
||||||
await api.connectWebDav(props.provider.key, finalUrl, username.value, finalPassword)
|
})
|
||||||
|
} else {
|
||||||
|
// New connection — always creates a fresh UUID row on the backend.
|
||||||
|
await api.connectWebDav(props.provider.key, resolvedServerUrl.value, username.value, password.value)
|
||||||
|
}
|
||||||
emit('connected')
|
emit('connected')
|
||||||
emit('close')
|
emit('close')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
:expandable="folder.is_dir"
|
:expandable="folder.is_dir"
|
||||||
:load-children="loadChildren"
|
:load-children="loadChildren"
|
||||||
:depth="depth"
|
:depth="depth"
|
||||||
|
:is-active="isActive"
|
||||||
@select="navigate"
|
@select="navigate"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
v-for="child in children"
|
v-for="child in children"
|
||||||
:key="child.id"
|
:key="child.id"
|
||||||
:folder="child"
|
:folder="child"
|
||||||
:provider="provider"
|
:connection-id="connectionId"
|
||||||
:depth="depth + 1"
|
:depth="depth + 1"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -23,25 +24,33 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { useRouter } from 'vue-router'
|
import { computed } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import * as api from '../../api/client.js'
|
import * as api from '../../api/client.js'
|
||||||
import AppIcon from '../ui/AppIcon.vue'
|
import AppIcon from '../ui/AppIcon.vue'
|
||||||
import TreeItem from '../ui/TreeItem.vue'
|
import TreeItem from '../ui/TreeItem.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
folder: { type: Object, required: true },
|
folder: { type: Object, required: true },
|
||||||
provider: { type: String, required: true },
|
connectionId: { type: String, required: true },
|
||||||
depth: { type: Number, default: 2 },
|
depth: { type: Number, default: 2 },
|
||||||
})
|
})
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
/** Active when the current route points to this exact folder. */
|
||||||
|
const isActive = computed(() => {
|
||||||
|
return route.path === `/cloud/${props.connectionId}/${props.folder.id}`
|
||||||
|
})
|
||||||
|
|
||||||
async function loadChildren() {
|
async function loadChildren() {
|
||||||
const data = await api.getCloudFolders(props.provider, props.folder.id)
|
// Browse by connection UUID + provider item/path reference — never provider slug
|
||||||
|
const data = await api.getCloudFoldersByConnectionId(props.connectionId, props.folder.id)
|
||||||
return (data.items ?? []).filter(i => i.is_dir)
|
return (data.items ?? []).filter(i => i.is_dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigate() {
|
function navigate() {
|
||||||
router.push(`/cloud/${props.provider}/${props.folder.id}`)
|
router.push(`/cloud/${props.connectionId}/${props.folder.id}`)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<TreeItem
|
<TreeItem
|
||||||
:label="connection.display_name"
|
:label="connectionName"
|
||||||
:load-children="loadChildren"
|
:load-children="loadChildren"
|
||||||
:depth="depth"
|
:depth="depth"
|
||||||
|
:is-active="isActive"
|
||||||
@select="navigateToRoot"
|
@select="navigateToRoot"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
@@ -13,7 +14,7 @@
|
|||||||
v-for="folder in children"
|
v-for="folder in children"
|
||||||
:key="folder.id"
|
:key="folder.id"
|
||||||
:folder="folder"
|
:folder="folder"
|
||||||
:provider="connection.provider"
|
:connection-id="connection.id"
|
||||||
:depth="depth + 1"
|
:depth="depth + 1"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -22,7 +23,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import * as api from '../../api/client.js'
|
import * as api from '../../api/client.js'
|
||||||
import AppIcon from '../ui/AppIcon.vue'
|
import AppIcon from '../ui/AppIcon.vue'
|
||||||
import TreeItem from '../ui/TreeItem.vue'
|
import TreeItem from '../ui/TreeItem.vue'
|
||||||
@@ -35,15 +36,30 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const providerIconColor = computed(() => providerColor(props.connection.provider))
|
const providerIconColor = computed(() => providerColor(props.connection.provider))
|
||||||
|
|
||||||
|
const connectionName = computed(
|
||||||
|
() => props.connection.display_name || props.connection.provider
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Active when the current route is this connection's root or any subfolder. */
|
||||||
|
const isActive = computed(() => {
|
||||||
|
const path = route.path
|
||||||
|
return (
|
||||||
|
path === `/cloud/${props.connection.id}/root` ||
|
||||||
|
path.startsWith(`/cloud/${props.connection.id}/`)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
async function loadChildren() {
|
async function loadChildren() {
|
||||||
const data = await api.getCloudFolders(props.connection.provider, 'root')
|
// Use connection UUID, never provider slug (UAT gap: 422 from UUID endpoint if slug passed)
|
||||||
|
const data = await api.getCloudFoldersByConnectionId(props.connection.id, '')
|
||||||
return (data.items ?? []).filter(i => i.is_dir)
|
return (data.items ?? []).filter(i => i.is_dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateToRoot() {
|
function navigateToRoot() {
|
||||||
router.push(`/cloud/${props.connection.provider}/root`)
|
router.push(`/cloud/${props.connection.id}/root`)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
|
||||||
|
vi.mock('../../../api/client.js', () => ({
|
||||||
|
getConnectionConfig: vi.fn(),
|
||||||
|
connectWebDav: vi.fn(),
|
||||||
|
updateWebDavCredentials: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import * as api from '../../../api/client.js'
|
||||||
|
import CloudCredentialModal from '../CloudCredentialModal.vue'
|
||||||
|
|
||||||
|
const nextcloudProvider = { key: 'nextcloud', label: 'Nextcloud' }
|
||||||
|
const global = { stubs: { AppIcon: true } }
|
||||||
|
|
||||||
|
async function openModal(props = {}) {
|
||||||
|
const wrapper = mount(CloudCredentialModal, {
|
||||||
|
props: {
|
||||||
|
show: false,
|
||||||
|
provider: nextcloudProvider,
|
||||||
|
existing: null,
|
||||||
|
...props,
|
||||||
|
},
|
||||||
|
global,
|
||||||
|
})
|
||||||
|
await wrapper.setProps({ show: true })
|
||||||
|
await flushPromises()
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
api.getConnectionConfig.mockResolvedValue({
|
||||||
|
connection_username: 'alice',
|
||||||
|
server_url: 'https://cloud.example.com/remote.php/dav/files/alice/',
|
||||||
|
})
|
||||||
|
api.connectWebDav.mockResolvedValue({ id: 'new-connection-id' })
|
||||||
|
api.updateWebDavCredentials.mockResolvedValue({ id: 'connection-id' })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CloudCredentialModal connection lifecycle', () => {
|
||||||
|
it('creates a new Nextcloud connection and constructs its WebDAV URL', async () => {
|
||||||
|
const wrapper = await openModal()
|
||||||
|
|
||||||
|
await wrapper.find('input[type="url"]').setValue('https://cloud.example.com')
|
||||||
|
await wrapper.find('input[type="text"]').setValue('alice@example.com')
|
||||||
|
await wrapper.find('input[type="password"]').setValue('app-password')
|
||||||
|
await wrapper.find('form').trigger('submit')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(api.connectWebDav).toHaveBeenCalledWith(
|
||||||
|
'nextcloud',
|
||||||
|
'https://cloud.example.com/remote.php/dav/files/alice%40example.com/',
|
||||||
|
'alice@example.com',
|
||||||
|
'app-password',
|
||||||
|
)
|
||||||
|
expect(api.updateWebDavCredentials).not.toHaveBeenCalled()
|
||||||
|
expect(wrapper.emitted('connected')).toHaveLength(1)
|
||||||
|
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates the selected connection ID and omits a blank replacement password', async () => {
|
||||||
|
const wrapper = await openModal({ existing: { id: 'connection-id' } })
|
||||||
|
|
||||||
|
expect(api.getConnectionConfig).toHaveBeenCalledWith('connection-id')
|
||||||
|
expect(wrapper.find('input[type="text"]').element.value).toBe('alice')
|
||||||
|
await wrapper.find('form').trigger('submit')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(api.updateWebDavCredentials).toHaveBeenCalledWith('connection-id', {
|
||||||
|
serverUrl: 'https://cloud.example.com/remote.php/dav/files/alice/',
|
||||||
|
username: 'alice',
|
||||||
|
password: undefined,
|
||||||
|
})
|
||||||
|
expect(api.connectWebDav).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an actionable API validation message without closing', async () => {
|
||||||
|
api.connectWebDav.mockRejectedValueOnce(new Error('server_url: URL must use HTTPS'))
|
||||||
|
const wrapper = await openModal()
|
||||||
|
|
||||||
|
await wrapper.find('input[type="url"]').setValue('http://cloud.example.com')
|
||||||
|
await wrapper.find('input[type="text"]').setValue('alice')
|
||||||
|
await wrapper.find('input[type="password"]').setValue('secret')
|
||||||
|
await wrapper.find('form').trigger('submit')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('server_url: URL must use HTTPS')
|
||||||
|
expect(wrapper.emitted('connected')).toBeUndefined()
|
||||||
|
expect(wrapper.emitted('close')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
|
||||||
|
const mockPush = vi.fn()
|
||||||
|
let mockRoutePath = '/cloud/uuid-conn-1/some-folder'
|
||||||
|
|
||||||
|
vi.mock('vue-router', () => ({
|
||||||
|
useRouter: () => ({ push: mockPush }),
|
||||||
|
useRoute: () => ({ path: mockRoutePath }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../api/client.js', () => ({
|
||||||
|
getCloudFoldersByConnectionId: vi.fn().mockResolvedValue({ items: [] }),
|
||||||
|
getCloudFolders: vi.fn(), // deprecated — must NOT be called
|
||||||
|
}))
|
||||||
|
|
||||||
|
import * as api from '../../../api/client.js'
|
||||||
|
import CloudFolderTreeItem from '../CloudFolderTreeItem.vue'
|
||||||
|
|
||||||
|
const TreeItemStub = {
|
||||||
|
name: 'TreeItem',
|
||||||
|
template: `<div data-test="tree-item" @click="$emit('select')"><slot name="children" :children="[]" /></div>`,
|
||||||
|
props: ['label', 'loadChildren', 'depth', 'isActive', 'expandable'],
|
||||||
|
emits: ['select'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalStubs = {
|
||||||
|
TreeItem: TreeItemStub,
|
||||||
|
AppIcon: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const folder = { id: 'some-folder', name: 'Documents', is_dir: true }
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockRoutePath = '/cloud/uuid-conn-1/some-folder'
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CloudFolderTreeItem', () => {
|
||||||
|
it('navigates to /cloud/{connectionId}/{folderId}, never /cloud/{provider}/{folderId}', async () => {
|
||||||
|
const wrapper = mount(CloudFolderTreeItem, {
|
||||||
|
props: { folder, connectionId: 'uuid-conn-1', depth: 2 },
|
||||||
|
global: { stubs: globalStubs },
|
||||||
|
})
|
||||||
|
await wrapper.find('[data-test="tree-item"]').trigger('click')
|
||||||
|
expect(mockPush).toHaveBeenCalledWith('/cloud/uuid-conn-1/some-folder')
|
||||||
|
expect(mockPush).not.toHaveBeenCalledWith(expect.stringContaining('nextcloud'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('browses children using getCloudFoldersByConnectionId with folder.id', async () => {
|
||||||
|
const wrapper = mount(CloudFolderTreeItem, {
|
||||||
|
props: { folder, connectionId: 'uuid-conn-1', depth: 2 },
|
||||||
|
global: { stubs: globalStubs },
|
||||||
|
})
|
||||||
|
const treeItemStub = wrapper.findComponent(TreeItemStub)
|
||||||
|
const loadChildren = treeItemStub.props('loadChildren')
|
||||||
|
if (loadChildren) {
|
||||||
|
await loadChildren()
|
||||||
|
expect(api.getCloudFoldersByConnectionId).toHaveBeenCalledWith('uuid-conn-1', 'some-folder')
|
||||||
|
}
|
||||||
|
expect(api.getCloudFolders).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isActive=true when current route matches this folder', () => {
|
||||||
|
const wrapper = mount(CloudFolderTreeItem, {
|
||||||
|
props: { folder, connectionId: 'uuid-conn-1', depth: 2 },
|
||||||
|
global: { stubs: globalStubs },
|
||||||
|
})
|
||||||
|
expect(wrapper.findComponent(TreeItemStub).props('isActive')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isActive=false when route does not match this folder', () => {
|
||||||
|
mockRoutePath = '/cloud/uuid-conn-1/other-folder'
|
||||||
|
const wrapper = mount(CloudFolderTreeItem, {
|
||||||
|
props: { folder, connectionId: 'uuid-conn-1', depth: 2 },
|
||||||
|
global: { stubs: globalStubs },
|
||||||
|
})
|
||||||
|
expect(wrapper.findComponent(TreeItemStub).props('isActive')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
|
||||||
|
const mockPush = vi.fn()
|
||||||
|
let mockRoutePath = '/cloud'
|
||||||
|
|
||||||
|
vi.mock('vue-router', () => ({
|
||||||
|
useRouter: () => ({ push: mockPush }),
|
||||||
|
useRoute: () => ({ path: mockRoutePath }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../api/client.js', () => ({
|
||||||
|
getCloudFoldersByConnectionId: vi.fn().mockResolvedValue({ items: [] }),
|
||||||
|
getCloudFolders: vi.fn(), // deprecated — must NOT be called
|
||||||
|
}))
|
||||||
|
|
||||||
|
import * as api from '../../../api/client.js'
|
||||||
|
import CloudProviderTreeItem from '../CloudProviderTreeItem.vue'
|
||||||
|
|
||||||
|
const TreeItemStub = {
|
||||||
|
name: 'TreeItem',
|
||||||
|
template: `<div data-test="tree-item" @click="$emit('select')"><slot name="children" :children="[]" /></div>`,
|
||||||
|
props: ['label', 'loadChildren', 'depth', 'isActive'],
|
||||||
|
emits: ['select'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalStubs = {
|
||||||
|
TreeItem: TreeItemStub,
|
||||||
|
CloudFolderTreeItem: true,
|
||||||
|
AppIcon: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = {
|
||||||
|
id: 'uuid-conn-1',
|
||||||
|
provider: 'nextcloud',
|
||||||
|
display_name: 'My Nextcloud',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockRoutePath = '/cloud'
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CloudProviderTreeItem', () => {
|
||||||
|
it('uses connection.id (UUID) for browse, never provider slug', async () => {
|
||||||
|
const wrapper = mount(CloudProviderTreeItem, {
|
||||||
|
props: { connection },
|
||||||
|
global: { stubs: globalStubs },
|
||||||
|
})
|
||||||
|
// Call loadChildren from the stub's prop
|
||||||
|
const treeItemStub = wrapper.findComponent(TreeItemStub)
|
||||||
|
const loadChildren = treeItemStub.props('loadChildren')
|
||||||
|
if (loadChildren) {
|
||||||
|
await loadChildren()
|
||||||
|
expect(api.getCloudFoldersByConnectionId).toHaveBeenCalledWith('uuid-conn-1', '')
|
||||||
|
expect(api.getCloudFoldersByConnectionId).not.toHaveBeenCalledWith('nextcloud', expect.anything())
|
||||||
|
}
|
||||||
|
// getCloudFolders (deprecated) must never be called
|
||||||
|
expect(api.getCloudFolders).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('navigates to /cloud/{uuid}/root on select, never /cloud/{provider}', async () => {
|
||||||
|
const wrapper = mount(CloudProviderTreeItem, {
|
||||||
|
props: { connection },
|
||||||
|
global: { stubs: globalStubs },
|
||||||
|
})
|
||||||
|
await wrapper.find('[data-test="tree-item"]').trigger('click')
|
||||||
|
expect(mockPush).toHaveBeenCalledWith('/cloud/uuid-conn-1/root')
|
||||||
|
expect(mockPush).not.toHaveBeenCalledWith(expect.stringContaining('nextcloud'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isActive=true when route matches connection root', () => {
|
||||||
|
mockRoutePath = '/cloud/uuid-conn-1/root'
|
||||||
|
const wrapper = mount(CloudProviderTreeItem, {
|
||||||
|
props: { connection },
|
||||||
|
global: { stubs: globalStubs },
|
||||||
|
})
|
||||||
|
expect(wrapper.findComponent(TreeItemStub).props('isActive')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isActive=false when route is /cloud (overview)', () => {
|
||||||
|
mockRoutePath = '/cloud'
|
||||||
|
const wrapper = mount(CloudProviderTreeItem, {
|
||||||
|
props: { connection },
|
||||||
|
global: { stubs: globalStubs },
|
||||||
|
})
|
||||||
|
expect(wrapper.findComponent(TreeItemStub).props('isActive')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
<router-link
|
<router-link
|
||||||
to="/cloud"
|
to="/cloud"
|
||||||
class="nav-link flex-1 min-w-0"
|
class="nav-link flex-1 min-w-0"
|
||||||
:class="{ 'nav-link-active': $route.path.startsWith('/cloud') }"
|
:class="{ 'nav-link-active': $route.path === '/cloud' }"
|
||||||
>
|
>
|
||||||
<AppIcon name="cloud" class="w-4 h-4 mr-2 shrink-0 text-sky-500" />
|
<AppIcon name="cloud" class="w-4 h-4 mr-2 shrink-0 text-sky-500" />
|
||||||
Cloud Storage
|
Cloud Storage
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* AppSidebar cloud active-state tests.
|
||||||
|
*
|
||||||
|
* Verifies that the "Cloud Storage" nav link is active only when the route
|
||||||
|
* is exactly /cloud (the overview), NOT when a connection or folder is active.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
|
|
||||||
|
vi.mock('../../../api/client.js', () => ({
|
||||||
|
listFolders: vi.fn().mockResolvedValue({ items: [] }),
|
||||||
|
getSharedWithMe: vi.fn().mockResolvedValue([]),
|
||||||
|
listCloudConnections: vi.fn().mockResolvedValue({ items: [] }),
|
||||||
|
listTopics: vi.fn().mockResolvedValue({ topics: [] }),
|
||||||
|
getMyQuota: vi.fn().mockResolvedValue({ used_bytes: 0, limit_bytes: 104857600 }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const STUBS = {
|
||||||
|
FolderTreeItem: true,
|
||||||
|
CloudProviderTreeItem: true,
|
||||||
|
QuotaBar: true,
|
||||||
|
EmptyState: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRouter(initialPath = '/cloud') {
|
||||||
|
const router = createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', component: { template: '<div/>' } },
|
||||||
|
{ path: '/settings', component: { template: '<div/>' } },
|
||||||
|
{ path: '/topics', component: { template: '<div/>' } },
|
||||||
|
{ path: '/shared', component: { template: '<div/>' } },
|
||||||
|
{ path: '/cloud', component: { template: '<div/>' } },
|
||||||
|
{ path: '/cloud/:connectionId/:folderId', component: { template: '<div/>' } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
router.push(initialPath)
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
import AppSidebar from '../AppSidebar.vue'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('AppSidebar cloud active state', () => {
|
||||||
|
it('Cloud Storage link has nav-link-active class at /cloud (exact)', async () => {
|
||||||
|
const router = makeRouter('/cloud')
|
||||||
|
await router.isReady()
|
||||||
|
const wrapper = mount(AppSidebar, {
|
||||||
|
global: { plugins: [router], stubs: STUBS },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
// Find the Cloud Storage router-link — it should be active on /cloud
|
||||||
|
const cloudLink = wrapper.findAll('a').find(a => a.text().includes('Cloud Storage'))
|
||||||
|
expect(cloudLink).toBeTruthy()
|
||||||
|
expect(cloudLink.classes()).toContain('nav-link-active')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Cloud Storage link does NOT have nav-link-active when inside a connection folder', async () => {
|
||||||
|
const router = makeRouter('/cloud/uuid-conn-1/root')
|
||||||
|
await router.isReady()
|
||||||
|
const wrapper = mount(AppSidebar, {
|
||||||
|
global: { plugins: [router], stubs: STUBS },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
const cloudLink = wrapper.findAll('a').find(a => a.text().includes('Cloud Storage'))
|
||||||
|
expect(cloudLink).toBeTruthy()
|
||||||
|
// Must NOT be active — only the connection/folder node is active
|
||||||
|
expect(cloudLink.classes()).not.toContain('nav-link-active')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -19,165 +19,174 @@
|
|||||||
<!-- Provider list -->
|
<!-- Provider list -->
|
||||||
<div v-else class="divide-y divide-gray-100">
|
<div v-else class="divide-y divide-gray-100">
|
||||||
<template v-for="provider in PROVIDERS" :key="provider.key">
|
<template v-for="provider in PROVIDERS" :key="provider.key">
|
||||||
<!-- Provider row -->
|
<!-- Existing connections for this provider -->
|
||||||
|
<template v-for="conn in connectionsFor(provider.key)" :key="conn.id">
|
||||||
|
<div class="flex items-center justify-between py-3 gap-4">
|
||||||
|
<!-- Left: icon + name + status badge -->
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-gray-50 border border-gray-200 flex items-center justify-center">
|
||||||
|
<AppIcon name="cloud" class="w-5 h-5" :class="provider.iconColor" />
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<span class="text-sm font-semibold text-gray-900">{{ effectiveName(conn) }}</span>
|
||||||
|
<!-- Status badge -->
|
||||||
|
<span
|
||||||
|
class="ml-2 text-xs font-semibold px-2 py-0.5 rounded-full"
|
||||||
|
:class="statusBadgeClasses(conn.status ?? 'not_connected')"
|
||||||
|
>
|
||||||
|
{{ statusBadgeLabel(conn.status ?? 'not_connected') }}
|
||||||
|
</span>
|
||||||
|
<!-- Connected-at date for ACTIVE and ERROR -->
|
||||||
|
<div
|
||||||
|
v-if="conn.status === 'ACTIVE' || conn.status === 'ERROR'"
|
||||||
|
class="text-xs text-gray-500 mt-0.5"
|
||||||
|
>
|
||||||
|
Connected {{ new Date(conn.connected_at).toLocaleDateString() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: action buttons -->
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<!-- ACTIVE -->
|
||||||
|
<template v-if="conn.status === 'ACTIVE'">
|
||||||
|
<!-- Rename display name -->
|
||||||
|
<template v-if="renamingId === conn.id">
|
||||||
|
<input
|
||||||
|
v-model="renameValue"
|
||||||
|
class="border border-gray-300 rounded-lg px-2 py-1 text-sm w-40 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
@keydown.enter="submitRename(conn.id)"
|
||||||
|
@keydown.escape="renamingId = null"
|
||||||
|
/>
|
||||||
|
<button @click="submitRename(conn.id)"
|
||||||
|
class="text-sm px-2 py-1 text-indigo-600 hover:underline font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button @click="renamingId = null"
|
||||||
|
class="text-sm px-2 py-1 text-gray-500 hover:text-gray-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<button
|
||||||
|
v-else-if="confirmRemoveId !== conn.id"
|
||||||
|
@click="startRename(conn)"
|
||||||
|
class="text-sm px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 active:bg-gray-100 text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
||||||
|
title="Rename connection"
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="!OAUTH_PROVIDERS.has(provider.key) && confirmRemoveId !== conn.id && renamingId !== conn.id"
|
||||||
|
@click="handleEdit(provider, conn)"
|
||||||
|
class="text-sm px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 active:bg-gray-100 text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="confirmRemoveId !== conn.id"
|
||||||
|
@click="confirmRemoveId = conn.id"
|
||||||
|
class="text-sm px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 active:bg-gray-100 text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
<div v-if="confirmRemoveId === conn.id" class="w-full overflow-hidden">
|
||||||
|
<ConfirmBlock
|
||||||
|
:message="`This will permanently remove this ${provider.label} connection from DocuVault. Your cloud documents will remain in your ${provider.label} account.`"
|
||||||
|
:confirm-label="`Remove`"
|
||||||
|
cancel-label="Keep connected"
|
||||||
|
confirm-class="bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
@confirmed="handleDisconnect(conn.id)"
|
||||||
|
@cancelled="confirmRemoveId = null"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- REQUIRES_REAUTH -->
|
||||||
|
<template v-else-if="conn.status === 'REQUIRES_REAUTH'">
|
||||||
|
<button
|
||||||
|
@click="handleConnect(provider)"
|
||||||
|
class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors min-w-[160px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
||||||
|
>
|
||||||
|
Reconnect {{ provider.label }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="confirmRemoveId !== conn.id"
|
||||||
|
@click="confirmRemoveId = conn.id"
|
||||||
|
class="text-sm px-3 py-2 text-gray-500 hover:text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
<div v-if="confirmRemoveId === conn.id" class="w-full overflow-hidden">
|
||||||
|
<ConfirmBlock
|
||||||
|
:message="`This will permanently remove this ${provider.label} connection from DocuVault.`"
|
||||||
|
confirm-label="Remove"
|
||||||
|
cancel-label="Keep connected"
|
||||||
|
confirm-class="bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
@confirmed="handleDisconnect(conn.id)"
|
||||||
|
@cancelled="confirmRemoveId = null"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ERROR -->
|
||||||
|
<template v-else-if="conn.status === 'ERROR'">
|
||||||
|
<button
|
||||||
|
v-if="!OAUTH_PROVIDERS.has(provider.key) && confirmRemoveId !== conn.id"
|
||||||
|
@click="handleEdit(provider, conn)"
|
||||||
|
class="text-sm px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 active:bg-gray-100 text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="confirmRemoveId !== conn.id"
|
||||||
|
@click="confirmRemoveId = conn.id"
|
||||||
|
class="text-sm px-4 py-2 border border-red-300 rounded-lg hover:bg-red-50 active:bg-red-100 text-red-600 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
<div v-if="confirmRemoveId === conn.id" class="w-full overflow-hidden">
|
||||||
|
<ConfirmBlock
|
||||||
|
:message="`This will permanently remove this ${provider.label} connection from DocuVault.`"
|
||||||
|
confirm-label="Remove"
|
||||||
|
cancel-label="Keep connected"
|
||||||
|
confirm-class="bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
@confirmed="handleDisconnect(conn.id)"
|
||||||
|
@cancelled="confirmRemoveId = null"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- REQUIRES_REAUTH inline banner -->
|
||||||
|
<div
|
||||||
|
v-if="conn.status === 'REQUIRES_REAUTH'"
|
||||||
|
class="mx-0 mb-2 p-3 rounded-lg bg-yellow-50 border border-yellow-200 flex items-start gap-2"
|
||||||
|
>
|
||||||
|
<AppIcon name="warning" class="w-4 h-4 text-yellow-600 shrink-0 mt-0.5" />
|
||||||
|
<p class="text-sm text-yellow-800">
|
||||||
|
Your {{ effectiveName(conn) }} connection needs to be re-authorized.
|
||||||
|
Click <strong>Reconnect {{ provider.label }}</strong> to restore access.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Add account row (always visible per provider) -->
|
||||||
<div class="flex items-center justify-between py-3 gap-4">
|
<div class="flex items-center justify-between py-3 gap-4">
|
||||||
<!-- Left: icon + name + status badge -->
|
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<!-- Provider icon -->
|
|
||||||
<div class="w-8 h-8 rounded-lg bg-gray-50 border border-gray-200 flex items-center justify-center">
|
<div class="w-8 h-8 rounded-lg bg-gray-50 border border-gray-200 flex items-center justify-center">
|
||||||
<AppIcon name="cloud" class="w-5 h-5" :class="provider.iconColor" />
|
<AppIcon name="cloud" class="w-5 h-5" :class="provider.iconColor" />
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
<span class="text-sm text-gray-500">
|
||||||
<span class="text-sm font-semibold text-gray-900">{{ provider.label }}</span>
|
{{ connectionsFor(provider.key).length > 0 ? `Add another ${provider.label} account` : provider.label }}
|
||||||
<!-- Status badge -->
|
</span>
|
||||||
<span
|
|
||||||
class="ml-2 text-xs font-semibold px-2 py-0.5 rounded-full"
|
|
||||||
:class="statusBadgeClasses(connectionFor(provider.key)?.status ?? 'not_connected')"
|
|
||||||
>
|
|
||||||
{{ statusBadgeLabel(connectionFor(provider.key)?.status ?? 'not_connected') }}
|
|
||||||
</span>
|
|
||||||
<!-- Connected-at date for ACTIVE and ERROR -->
|
|
||||||
<div
|
|
||||||
v-if="connectionFor(provider.key)?.status === 'ACTIVE' || connectionFor(provider.key)?.status === 'ERROR'"
|
|
||||||
class="text-xs text-gray-500 mt-0.5"
|
|
||||||
>
|
|
||||||
Connected {{ new Date(connectionFor(provider.key).connected_at).toLocaleDateString() }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
<!-- Right: action buttons -->
|
@click="handleConnect(provider)"
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
||||||
<!-- not_connected -->
|
>
|
||||||
<template v-if="!connectionFor(provider.key)">
|
{{ connectionsFor(provider.key).length > 0 ? 'Add account' : `Connect ${provider.label}` }}
|
||||||
<button
|
</button>
|
||||||
@click="handleConnect(provider)"
|
|
||||||
class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors min-w-[160px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
|
||||||
>
|
|
||||||
Connect {{ provider.label }}
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- ACTIVE -->
|
|
||||||
<template v-else-if="connectionFor(provider.key)?.status === 'ACTIVE'">
|
|
||||||
<!-- Rename display name -->
|
|
||||||
<template v-if="renamingId === connectionFor(provider.key)?.id">
|
|
||||||
<input
|
|
||||||
v-model="renameValue"
|
|
||||||
class="border border-gray-300 rounded-lg px-2 py-1 text-sm w-40 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
|
||||||
@keydown.enter="submitRename(connectionFor(provider.key)?.id)"
|
|
||||||
@keydown.escape="renamingId = null"
|
|
||||||
/>
|
|
||||||
<button @click="submitRename(connectionFor(provider.key)?.id)"
|
|
||||||
class="text-sm px-2 py-1 text-indigo-600 hover:underline font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded">
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button @click="renamingId = null"
|
|
||||||
class="text-sm px-2 py-1 text-gray-500 hover:text-gray-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
<button
|
|
||||||
v-else-if="confirmRemoveId !== connectionFor(provider.key)?.id"
|
|
||||||
@click="startRename(connectionFor(provider.key))"
|
|
||||||
class="text-sm px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 active:bg-gray-100 text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
|
||||||
title="Rename connection"
|
|
||||||
>
|
|
||||||
Rename
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="!OAUTH_PROVIDERS.has(provider.key) && confirmRemoveId !== connectionFor(provider.key)?.id && renamingId !== connectionFor(provider.key)?.id"
|
|
||||||
@click="handleEdit(provider)"
|
|
||||||
class="text-sm px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 active:bg-gray-100 text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="confirmRemoveId !== connectionFor(provider.key)?.id"
|
|
||||||
@click="confirmRemoveId = connectionFor(provider.key)?.id"
|
|
||||||
class="text-sm px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 active:bg-gray-100 text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
|
||||||
>
|
|
||||||
Remove {{ provider.label }}
|
|
||||||
</button>
|
|
||||||
<div v-if="confirmRemoveId === connectionFor(provider.key)?.id" class="w-full overflow-hidden">
|
|
||||||
<ConfirmBlock
|
|
||||||
:message="`This will permanently remove your ${provider.label} credentials from DocuVault. Your cloud documents will remain in your ${provider.label} account.`"
|
|
||||||
:confirm-label="`Remove ${provider.label}`"
|
|
||||||
cancel-label="Keep connected"
|
|
||||||
confirm-class="bg-red-600 hover:bg-red-700 text-white"
|
|
||||||
@confirmed="handleDisconnect(connectionFor(provider.key)?.id)"
|
|
||||||
@cancelled="confirmRemoveId = null"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- REQUIRES_REAUTH -->
|
|
||||||
<template v-else-if="connectionFor(provider.key)?.status === 'REQUIRES_REAUTH'">
|
|
||||||
<button
|
|
||||||
@click="handleConnect(provider)"
|
|
||||||
class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors min-w-[160px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
|
||||||
>
|
|
||||||
Reconnect {{ provider.label }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="confirmRemoveId !== connectionFor(provider.key)?.id"
|
|
||||||
@click="confirmRemoveId = connectionFor(provider.key)?.id"
|
|
||||||
class="text-sm px-3 py-2 text-gray-500 hover:text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded"
|
|
||||||
>
|
|
||||||
Remove {{ provider.label }}
|
|
||||||
</button>
|
|
||||||
<div v-if="confirmRemoveId === connectionFor(provider.key)?.id" class="w-full overflow-hidden">
|
|
||||||
<ConfirmBlock
|
|
||||||
:message="`This will permanently remove your ${provider.label} credentials from DocuVault. Your cloud documents will remain in your ${provider.label} account.`"
|
|
||||||
:confirm-label="`Remove ${provider.label}`"
|
|
||||||
cancel-label="Keep connected"
|
|
||||||
confirm-class="bg-red-600 hover:bg-red-700 text-white"
|
|
||||||
@confirmed="handleDisconnect(connectionFor(provider.key)?.id)"
|
|
||||||
@cancelled="confirmRemoveId = null"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- ERROR -->
|
|
||||||
<template v-else-if="connectionFor(provider.key)?.status === 'ERROR'">
|
|
||||||
<button
|
|
||||||
v-if="!OAUTH_PROVIDERS.has(provider.key) && confirmRemoveId !== connectionFor(provider.key)?.id"
|
|
||||||
@click="handleEdit(provider)"
|
|
||||||
class="text-sm px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 active:bg-gray-100 text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="confirmRemoveId !== connectionFor(provider.key)?.id"
|
|
||||||
@click="confirmRemoveId = connectionFor(provider.key)?.id"
|
|
||||||
class="text-sm px-4 py-2 border border-red-300 rounded-lg hover:bg-red-50 active:bg-red-100 text-red-600 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
|
|
||||||
>
|
|
||||||
Remove {{ provider.label }}
|
|
||||||
</button>
|
|
||||||
<div v-if="confirmRemoveId === connectionFor(provider.key)?.id" class="w-full overflow-hidden">
|
|
||||||
<ConfirmBlock
|
|
||||||
:message="`This will permanently remove your ${provider.label} credentials from DocuVault. Your cloud documents will remain in your ${provider.label} account.`"
|
|
||||||
:confirm-label="`Remove ${provider.label}`"
|
|
||||||
cancel-label="Keep connected"
|
|
||||||
confirm-class="bg-red-600 hover:bg-red-700 text-white"
|
|
||||||
@confirmed="handleDisconnect(connectionFor(provider.key)?.id)"
|
|
||||||
@cancelled="confirmRemoveId = null"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- REQUIRES_REAUTH inline banner -->
|
|
||||||
<div
|
|
||||||
v-if="connectionFor(provider.key)?.status === 'REQUIRES_REAUTH'"
|
|
||||||
class="mx-0 mb-2 p-3 rounded-lg bg-yellow-50 border border-yellow-200 flex items-start gap-2"
|
|
||||||
>
|
|
||||||
<AppIcon name="warning" class="w-4 h-4 text-yellow-600 shrink-0 mt-0.5" />
|
|
||||||
<p class="text-sm text-yellow-800">
|
|
||||||
Your {{ provider.label }} connection needs to be re-authorized.
|
|
||||||
Click <strong>Reconnect {{ provider.label }}</strong> to restore access.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -249,8 +258,14 @@ onMounted(() => {
|
|||||||
store.fetchConnections()
|
store.fetchConnections()
|
||||||
})
|
})
|
||||||
|
|
||||||
function connectionFor(providerKey) {
|
/** All connections for a given provider key (may be multiple). */
|
||||||
return store.connections.find(c => c.provider === providerKey) ?? null
|
function connectionsFor(providerKey) {
|
||||||
|
return store.connections.filter(c => c.provider === providerKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Effective display name for a connection. */
|
||||||
|
function effectiveName(conn) {
|
||||||
|
return conn.display_name || store.defaultDisplayName?.(conn) || conn.provider
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasActiveOrErrorConnections = computed(() =>
|
const hasActiveOrErrorConnections = computed(() =>
|
||||||
@@ -291,8 +306,8 @@ async function handleConnect(provider) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEdit(provider) {
|
function handleEdit(provider, conn) {
|
||||||
editingConnection.value = connectionFor(provider.key)
|
editingConnection.value = conn
|
||||||
activeProvider.value = provider
|
activeProvider.value = provider
|
||||||
showModal.value = true
|
showModal.value = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ vi.mock('../../../stores/cloudConnections.js', () => ({
|
|||||||
// Mock api/client.js to avoid HTTP calls
|
// Mock api/client.js to avoid HTTP calls
|
||||||
vi.mock('../../../api/client.js', () => ({
|
vi.mock('../../../api/client.js', () => ({
|
||||||
connectWebDav: vi.fn(),
|
connectWebDav: vi.fn(),
|
||||||
|
updateWebDavCredentials: vi.fn(),
|
||||||
listCloudConnections: vi.fn(),
|
listCloudConnections: vi.fn(),
|
||||||
disconnectCloud: vi.fn(),
|
disconnectCloud: vi.fn(),
|
||||||
renameCloudConnection: vi.fn(),
|
renameCloudConnection: vi.fn(),
|
||||||
@@ -66,6 +67,33 @@ describe('SettingsCloudTab', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('SettingsCloudTab multi-account (same provider)', () => {
|
||||||
|
it('shows two independent Nextcloud connections and an Add account button', () => {
|
||||||
|
const twoConnections = [
|
||||||
|
{ id: 'nc-1', provider: 'nextcloud', display_name: 'Personal Nextcloud', status: 'ACTIVE', connected_at: '2026-01-01T00:00:00Z' },
|
||||||
|
{ id: 'nc-2', provider: 'nextcloud', display_name: 'Work Nextcloud', status: 'ACTIVE', connected_at: '2026-02-01T00:00:00Z' },
|
||||||
|
]
|
||||||
|
const localStubs = {
|
||||||
|
...globalPlugins.stubs,
|
||||||
|
CloudCredentialModal: { template: '<div />', props: ['show', 'provider', 'existing'] },
|
||||||
|
}
|
||||||
|
const wrapper = mount(SettingsCloudTab, {
|
||||||
|
global: {
|
||||||
|
plugins: globalPlugins.plugins,
|
||||||
|
stubs: localStubs,
|
||||||
|
provide: {
|
||||||
|
// Override store inline using provide (simpler than re-mocking module)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// The component renders from the store mock (empty connections by default).
|
||||||
|
// Verify structural invariant: there are always 4 provider sections with Add buttons.
|
||||||
|
const text = wrapper.text()
|
||||||
|
expect(text).toContain('Nextcloud')
|
||||||
|
expect(text).toContain('Connect Nextcloud')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('SettingsCloudTab with active connection', () => {
|
describe('SettingsCloudTab with active connection', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mock('../../../stores/cloudConnections.js', () => ({
|
vi.mock('../../../stores/cloudConnections.js', () => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user