67edc19a36
Plan refinements: Vitest tests added to 09/10 must-haves, explicit mock_flow two-tuple pattern in 10, test_admin_api.py fixture usage in 11. New artifacts: UAT checklist, UI-SPEC, deferred-items, debug investigation for cloud-doc-operations-fail. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
236 lines
15 KiB
Markdown
236 lines
15 KiB
Markdown
---
|
|
phase: 05-cloud-storage-backends
|
|
plan: 10
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- backend/api/cloud.py
|
|
- frontend/src/components/settings/SettingsCloudTab.vue
|
|
- frontend/src/components/cloud/CloudCredentialModal.vue
|
|
- frontend/src/components/ui/ConfirmBlock.vue
|
|
- backend/tests/test_cloud.py
|
|
- frontend/src/components/settings/__tests__/SettingsCloudTab.test.js
|
|
autonomous: true
|
|
requirements: [CLOUD-01, CLOUD-04]
|
|
gap_closure: true
|
|
|
|
must_haves:
|
|
truths:
|
|
- "Clicking Connect on Google Drive or OneDrive initiates OAuth without a 401"
|
|
- "Nextcloud custom endpoint is preserved when re-editing an existing connection"
|
|
- "Edit button appears on ERROR-state Nextcloud/WebDAV rows"
|
|
- "Disconnect confirmation text renders fully within its container without overflow"
|
|
- "handleConnect on OAuth providers calls initiateOAuth and navigates to the returned URL (verified by Vitest)"
|
|
artifacts:
|
|
- path: "backend/api/cloud.py"
|
|
provides: "oauth_initiate returns JSON {url} (200) instead of RedirectResponse (302)"
|
|
- path: "frontend/src/components/settings/SettingsCloudTab.vue"
|
|
provides: "handleConnect uses fetch() + Bearer header; Edit button in ERROR template block"
|
|
- path: "frontend/src/components/cloud/CloudCredentialModal.vue"
|
|
provides: "watch handler detects custom endpoint on edit and repopulates showAdvanced + customEndpoint"
|
|
- path: "frontend/src/components/ui/ConfirmBlock.vue"
|
|
provides: "break-words on message paragraph"
|
|
- path: "frontend/src/components/settings/__tests__/SettingsCloudTab.test.js"
|
|
provides: "Vitest test asserting handleConnect calls initiateOAuth and sets window.location.href"
|
|
key_links:
|
|
- from: "frontend/src/components/settings/SettingsCloudTab.vue"
|
|
to: "/api/cloud/oauth/initiate/{provider}"
|
|
via: "fetch() with Authorization header → data.url → window.location.href"
|
|
- from: "backend/api/cloud.py"
|
|
to: "oauth_initiate handler"
|
|
via: "returns JSONResponse({url: authorization_url}) status 200"
|
|
---
|
|
|
|
<objective>
|
|
Fix four cloud settings UI gaps: OAuth initiate 401, Nextcloud custom endpoint lost on edit, missing Edit button on ERROR rows, and confirmation text overflow.
|
|
|
|
Purpose: Users cannot connect OAuth providers (Google Drive/OneDrive) due to a bare browser navigation that carries no auth header. Nextcloud connections with custom endpoints lose their configuration on re-edit. ERROR-state connections cannot be edited without removing first.
|
|
|
|
Output: OAuth flow initiates correctly, Nextcloud edit round-trips custom endpoint, ERROR rows have Edit button, confirmation text wraps.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/05-cloud-storage-backends/05-UAT.md
|
|
</context>
|
|
|
|
<interfaces>
|
|
<!-- Key contracts the executor needs. -->
|
|
|
|
From backend/api/cloud.py (current oauth_initiate):
|
|
- Route: GET /api/cloud/oauth/initiate/{provider}
|
|
- Currently returns: RedirectResponse(url=authorization_url, status_code=302)
|
|
- Requires: Depends(get_regular_user)
|
|
- State token stored in Redis; OAuth URL constructed from google/onedrive SDKs
|
|
- Change: return JSONResponse({"url": authorization_url}) with status 200
|
|
|
|
From frontend/src/components/settings/SettingsCloudTab.vue (current handleConnect):
|
|
- OAUTH_PROVIDERS = new Set(['google_drive', 'onedrive'])
|
|
- Currently: window.location.href = `/api/cloud/oauth/initiate/${provider.key}`
|
|
- Change: fetch with Authorization header, then window.location.href = data.url
|
|
|
|
From frontend/src/api/client.js:
|
|
- The `request()` function is JSON-only (calls res.json()). For OAuth initiate, we want JSON back (the {url} object), so `request()` can be used directly.
|
|
- Export: `initiateOAuth(provider)` → calls request(`/api/cloud/oauth/initiate/${provider}`)
|
|
|
|
From frontend/src/components/cloud/CloudCredentialModal.vue (watch handler, lines 191-209):
|
|
- Nextcloud edit: extracts only hostname with regex match[1], clears customEndpoint
|
|
- Bug: if stored server_url does NOT match /remote.php/dav/files/{username}/ pattern, the custom endpoint is silently lost
|
|
- Fix: compare resolvedServerUrl (auto-constructed) to existing.server_url; if they differ, populate customEndpoint with the stored URL and set showAdvanced=true
|
|
|
|
From frontend/src/components/settings/SettingsCloudTab.vue (ERROR template, lines 88-96):
|
|
- Only has Remove button
|
|
- Add Edit button identical to the one in the ACTIVE block (same v-if guard: !OAUTH_PROVIDERS.has(provider.key))
|
|
|
|
From frontend/src/components/ui/ConfirmBlock.vue:
|
|
- The message <p> has class "text-sm text-gray-700" — add "break-words" to it
|
|
- The wrapper div in SettingsCloudTab.vue (lines 100-113) needs "w-full overflow-hidden" on the outer div
|
|
</interfaces>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: Fix OAuth initiate — return JSON URL instead of 302 redirect</name>
|
|
<files>backend/api/cloud.py, backend/tests/test_cloud.py</files>
|
|
<behavior>
|
|
- GET /api/cloud/oauth/initiate/google_drive with valid Bearer token returns 200 JSON {url: "https://accounts.google.com/..."}.
|
|
- GET /api/cloud/oauth/initiate/onedrive with valid Bearer token returns 200 JSON {url: "https://login.microsoftonline.com/..."}.
|
|
- GET /api/cloud/oauth/initiate/invalid_provider returns 400 (unchanged).
|
|
- GET /api/cloud/oauth/initiate/google_drive with no auth returns 401 or 403 (unchanged behavior — get_regular_user enforces this).
|
|
</behavior>
|
|
<action>
|
|
In backend/api/cloud.py, change the `oauth_initiate` handler:
|
|
- Remove `response_class=RedirectResponse` from the `@router.get` decorator.
|
|
- Replace the two `return RedirectResponse(url=authorization_url, status_code=302)` statements (one for google_drive, one for onedrive) with `from fastapi.responses import JSONResponse` (already imported via fastapi) and return `JSONResponse({"url": authorization_url})`.
|
|
- The state token generation, Redis storage, and authorization URL construction are unchanged.
|
|
- Update the return type annotation if present.
|
|
|
|
In backend/tests/test_cloud.py, add test `test_oauth_initiate_returns_json_url`:
|
|
- Creates a regular user + token.
|
|
- Mocks Redis setex (the app state redis client).
|
|
- For google_drive provider: mocks `google_auth_oauthlib.flow.Flow.from_client_config` to return a mock flow object. Set the mock explicitly: `mock_flow.authorization_url.return_value = ("https://accounts.google.com/test", "state123")` — do not use a generic "returns a URL" — this exact two-tuple assignment is required because `flow.authorization_url(...)` returns `(url, state)` and the handler unpacks both values.
|
|
- Calls GET /api/cloud/oauth/initiate/google_drive with Bearer header.
|
|
- Asserts response.status_code == 200.
|
|
- Asserts response.json()["url"].startswith("https://accounts.google.com/").
|
|
- Also adds `test_oauth_initiate_requires_auth`: calls without token, asserts 401 or 403.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -m pytest tests/test_cloud.py::test_oauth_initiate_returns_json_url tests/test_cloud.py::test_oauth_initiate_requires_auth -v</automated>
|
|
</verify>
|
|
<done>Both tests pass. oauth_initiate returns 200 JSON {url}. No RedirectResponse in handler.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Frontend OAuth fetch, Nextcloud edit fix, Edit on ERROR, text overflow + Vitest test</name>
|
|
<files>frontend/src/components/settings/SettingsCloudTab.vue, frontend/src/components/cloud/CloudCredentialModal.vue, frontend/src/components/ui/ConfirmBlock.vue, frontend/src/api/client.js, frontend/src/components/settings/__tests__/SettingsCloudTab.test.js</files>
|
|
<action>
|
|
### 1. client.js — add initiateOAuth helper
|
|
Export `initiateOAuth(provider)` that calls `request(`/api/cloud/oauth/initiate/${provider}`)`. This uses the existing `request()` helper which injects the Bearer header and handles 401 → refresh → retry.
|
|
|
|
### 2. SettingsCloudTab.vue — fix handleConnect for OAuth providers
|
|
Replace the current `window.location.href = /api/cloud/oauth/initiate/${provider.key}` line in `handleConnect` with:
|
|
```
|
|
const data = await initiateOAuth(provider.key)
|
|
window.location.href = data.url
|
|
```
|
|
Import `initiateOAuth` from `../../api/client.js`. Wrap in try/catch; on error show a toast or set a reactive `oauthError` ref displayed above the provider list.
|
|
|
|
### 3. SettingsCloudTab.vue — add Edit button to ERROR template block
|
|
In the `<!-- ERROR -->` template block (currently lines 88-96), add an Edit button before the Remove button, mirroring the ACTIVE block exactly:
|
|
```html
|
|
<button
|
|
v-if="!OAUTH_PROVIDERS.has(provider.key)"
|
|
@click="handleEdit(provider)"
|
|
class="text-sm px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-700 transition-colors"
|
|
>
|
|
Edit
|
|
</button>
|
|
```
|
|
|
|
### 4. SettingsCloudTab.vue — fix confirmation wrapper overflow
|
|
Add `w-full overflow-hidden` to the outer `<div>` that wraps the `<ConfirmBlock>` component (the div at the current line starting `v-if="confirmRemoveId === connectionFor(provider.key)?.id"`). This div needs to break out of the flex row — it is already rendered outside the `flex items-center` row as a sibling block, so adding `w-full` and `overflow-hidden` is sufficient.
|
|
|
|
### 5. CloudCredentialModal.vue — fix Nextcloud edit custom endpoint pre-population
|
|
In the watch handler on `props.show`, after the existing logic that sets `serverBase.value` by extracting the hostname:
|
|
- Compute what the auto-constructed URL would be using the extracted hostname and `existing.connection_username`:
|
|
`const autoUrl = match ? \`${match[1]}/remote.php/dav/files/${encodeURIComponent(existing.connection_username ?? '')}/\` : ''`
|
|
- If `existing.server_url !== autoUrl` (the stored URL doesn't match the auto-constructed pattern) AND `existing.server_url` has a path beyond just the hostname, then:
|
|
- Set `showAdvanced.value = true`
|
|
- Set `customEndpoint.value = existing.server_url`
|
|
- Otherwise leave `showAdvanced.value = false` and `customEndpoint.value = ''` (existing behavior).
|
|
|
|
### 6. ConfirmBlock.vue — add break-words to message paragraph
|
|
Change `<p class="text-sm text-gray-700">` to `<p class="text-sm text-gray-700 break-words">`.
|
|
|
|
### 7. SettingsCloudTab.test.js — add Vitest test for handleConnect OAuth flow
|
|
The file `frontend/src/components/settings/__tests__/SettingsCloudTab.test.js` already exists. Add a new test asserting the corrected OAuth connect flow:
|
|
- Mock `initiateOAuth` from `../../../../api/client.js` using `vi.mock`: `vi.mock('../../../../api/client.js', () => ({ initiateOAuth: vi.fn() }))`.
|
|
- In the test, set `initiateOAuth.mockResolvedValue({ url: 'https://accounts.google.com/o/oauth2/auth?state=xyz' })`.
|
|
- Assign `window.location = { href: '' }` (use `Object.defineProperty` or `vi.stubGlobal` to make `window.location.href` writable in the test environment).
|
|
- Mount `SettingsCloudTab` with a stubbed auth store that has a valid accessToken.
|
|
- Find a Connect button for a provider with `key === 'google_drive'` and trigger a click event simulating `handleConnect({ key: 'google_drive' })`.
|
|
- Assert: `initiateOAuth` was called with `'google_drive'`.
|
|
- Assert: `window.location.href` was set to `'https://accounts.google.com/o/oauth2/auth?state=xyz'` (the exact URL returned by the mock — confirming the component navigates to `data.url` and not directly to `/api/cloud/...`).
|
|
- Use `afterEach(() => vi.restoreAllMocks())`.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /Users/nik/Documents/Progamming/document_scanner/frontend && npm run test -- --reporter=verbose --run src/components/settings/__tests__/SettingsCloudTab.test.js 2>&1 | tail -20 && npm run build 2>&1 | tail -5</automated>
|
|
</verify>
|
|
<done>Vitest test for handleConnect OAuth flow passes. Frontend build passes with zero errors. All four UI changes are applied: OAuth uses fetch, ERROR rows have Edit button, Nextcloud watch handler preserves custom endpoint, ConfirmBlock message has break-words.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
|----------|-------------|
|
|
| frontend→/api/cloud/oauth/initiate | Now goes through fetch() with Bearer header instead of bare browser navigation |
|
|
| OAuth URL returned to frontend | URL is generated by the backend OAuth library and stored state in Redis — frontend only receives the URL string |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|-------------|-----------------|
|
|
| T-05-10-01 | Spoofing | oauth_initiate auth | mitigate | get_regular_user still enforced — only authenticated users receive the OAuth URL |
|
|
| T-05-10-02 | Information Disclosure | OAuth URL in JSON response | accept | URL is a standard OAuth authorization URL with CSRF state token — no credentials in the URL |
|
|
| T-05-10-03 | Tampering | OAuth state token | mitigate | State token generated server-side (secrets.token_urlsafe(32)), stored in Redis with TTL 1800, single-use (deleted in callback) — unchanged from original design |
|
|
| T-05-10-04 | Spoofing | Nextcloud custom endpoint re-edit | accept | Pre-populated values come from encrypted DB credentials decrypted server-side and returned as server_url field — not user-alterable before display |
|
|
| T-05-10-SC | Tampering | npm/pip installs | mitigate | No new packages installed in this plan |
|
|
</threat_model>
|
|
|
|
<verification>
|
|
After both tasks complete:
|
|
- `pytest backend/tests/test_cloud.py::test_oauth_initiate_returns_json_url backend/tests/test_cloud.py::test_oauth_initiate_requires_auth -v`
|
|
- `npm run test -- --run src/components/settings/__tests__/SettingsCloudTab.test.js` — Vitest test for handleConnect OAuth flow passes
|
|
- `npm run build` — zero errors
|
|
- Manual: click Connect on Google Drive — browser navigates to accounts.google.com (not localhost 401)
|
|
- Manual: edit Nextcloud connection with custom endpoint — Advanced section opens with endpoint pre-filled
|
|
- Manual: connection in ERROR state — Edit button is visible
|
|
- Manual: disconnect confirmation text wraps within its container
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- oauth_initiate returns 200 JSON {url} (not 302 redirect)
|
|
- Two new pytest tests pass for OAuth initiate; mock uses explicit `mock_flow.authorization_url.return_value = ("https://accounts.google.com/test", "state123")` two-tuple
|
|
- handleConnect in SettingsCloudTab uses fetch() + initiateOAuth(); no window.location.href to /api path
|
|
- Vitest test asserts initiateOAuth called with provider key and window.location.href set to returned URL
|
|
- ERROR status template block has Edit button for non-OAuth providers
|
|
- CloudCredentialModal watch handler repopulates customEndpoint and showAdvanced when stored URL differs from auto-constructed pattern
|
|
- ConfirmBlock message paragraph has break-words class
|
|
- Full frontend build and pytest suite have zero new failures
|
|
</success_criteria>
|
|
|
|
<output>
|
|
Create `.planning/phases/05-cloud-storage-backends/05-10-SUMMARY.md` when done
|
|
</output>
|