feat(12-02): add refresh_cloud_folder Celery task, staleness trigger, version 0.1.5

- Add tasks/cloud_tasks.py: durable refresh_cloud_folder task with 3-retry
  bounded backoff (30s/90s/270s +jitter), credential decryption in worker only
- Register tasks.cloud_tasks.* on documents queue in celery_app.py
- Add stale-while-revalidate staleness trigger in browse.py (5-min threshold)
- Add 4 Task 3 tests: idempotency, cached-row retention on failure, task structure,
  no-byte-download contract; add background-refresh scheduling integration test
- Bump backend version 0.1.4 → 0.1.5, frontend package.json 0.1.4 → 0.1.5
- Update AGENTS.md with Phase 12 Plan 02 state and new shared module map entries
- Update README with connection-ID browse API table and v0.1.5
This commit is contained in:
curo1305
2026-06-18 23:17:34 +02:00
parent e186019066
commit c6237ca57f
9 changed files with 735 additions and 6 deletions
+329
View File
@@ -0,0 +1,329 @@
# DocuVault — Codex Guide
## Project Overview
DocuVault is a multi-user SaaS document management platform built on FastAPI (Python) + Vue 3. It handles document upload, text extraction (PDF/DOCX/image/text), AI-based topic classification, per-user isolated storage, folder organization, document sharing, and pluggable cloud storage backends (OneDrive, Google Drive, Nextcloud, WebDAV).
**Current state:** v0.1.5 — Phase 12 Plan 02 complete (2026-06-18). Connection-ID browse API (`GET /api/cloud/connections/{connection_id}/items`) with owner-scoped IDOR protection, stale-while-revalidate caching, and durable `refresh_cloud_folder` Celery task. All 4 providers implement `CloudResourceAdapter`. Not cleared for public internet deployment.
## Stack
- **Backend:** Python 3.12, FastAPI 0.136+, SQLAlchemy 2.0 async, psycopg v3, Alembic, MinIO SDK
- **Frontend:** Vue 3 (Options API), Pinia, Vue Router 4, Vite, Tailwind CSS
- **Infrastructure:** Docker Compose, PostgreSQL, MinIO (S3-compatible)
- **Auth:** PyJWT 2.12+, pwdlib[argon2], pyotp (TOTP), cryptography (Fernet/HKDF)
## Key Architectural Rules
- JWT access token lives in **Pinia memory only** — never localStorage or sessionStorage
- Refresh token is an **httpOnly; Secure; SameSite=Strict cookie** — never accessible to JavaScript
- MinIO object keys are **UUID-based** (`{user_id}/{document_id}/{uuid4()}{ext}`) — human filenames in DB only
- Cloud credentials encrypted with **HKDF per-user key derivation** — master key in env var only
- Quota enforced atomically: **`UPDATE quotas SET used_bytes = used_bytes + $delta WHERE (used_bytes + $delta) <= limit_bytes RETURNING used_bytes`**
- Admin endpoints **never return** document content, extracted text, or `credentials_enc`
- Every document/folder endpoint asserts `resource.user_id == current_user.id`
- All DB queries via ORM / parameterized statements — zero raw string interpolation
- Cloud browse/refresh **never downloads file bytes** and never mutates quota (D-18/CACHE-01)
- `refresh_cloud_folder` Celery task decrypts credentials inside the worker — never in broker payload
## Code Standards (Non-Negotiable)
### Core principle
**Things that look the same to the user are the same in code.** Local file navigation and cloud file navigation share one component. Sidebar folder trees and cloud trees share one component. Format helpers exist once. If you are about to write the same logic a second time, extract it first.
### Backend: shared module map
Before adding a helper, check if it belongs in an existing shared module:
| Module | What lives here |
|---|---|
| `backend/deps/utils.py` | `get_client_ip(request)`, `parse_uuid(value)` — request-parsing helpers used across all routers |
| `backend/storage/exceptions.py` | `CloudConnectionError` — single canonical definition; all files import from here |
| `backend/ai/utils.py` | `strip_code_fences`, `parse_classification`, `parse_suggestions` — AI response parsing shared by all providers |
| `backend/services/auth.py` | `validate_password_strength(password)` — raises `ValueError`; routers catch and re-raise as `HTTPException` |
| `backend/storage/cloud_base.py` | `CloudResourceAdapter`, `CloudListing`, `CloudResource`, `CloudCapability` — Phase 12 browse contract |
| `backend/services/cloud_items.py` | `resolve_owned_connection`, `upsert_cloud_item`, `reconcile_cloud_listing` — owner-scoped metadata service |
| `backend/api/cloud/schemas.py` | `CloudBrowseResponse`, `CloudItemOut`, `FolderFreshnessOut` — credential-free response schemas |
**Rules:**
- No router may define `_ip()`, `_get_ip()`, or any other local variant of `get_client_ip`. Import from `deps.utils`.
- No router may define its own `CloudConnectionError`. Import from `storage.exceptions`.
- No AI provider may define its own `_strip_code_fences` or `_parse_*`. Import from `ai.utils`.
- No API file may define `_validate_password_strength`. Import from `services.auth`.
- Service layer raises `ValueError` (or domain exceptions), never `HTTPException`. Only the router layer raises `HTTPException`.
- Cloud browse responses must use `api/cloud/schemas.py` whitelisted types — never return raw ORM objects.
- `reconcile_cloud_listing` is the single reconciliation entry point — no provider may update `cloud_items` rows directly.
### Frontend: shared module map
| Module | What lives here |
|---|---|
| `src/utils/formatters.js` | `formatDate`, `formatSize`, `providerColor`, `providerBg`, `providerLabel` |
| `src/components/ui/TreeItem.vue` | Generic expand/collapse tree node — all sidebar tree items wrap this |
| `src/components/storage/StorageBrowser.vue` | Unified file browser grid — used by both `FileManagerView` and `CloudFolderView` |
**Rules:**
- No component may define its own `formatDate` or `formatSize`. Always import from `utils/formatters.js`.
- No component may define its own `providerColor` or `providerBg`. Always import from `utils/formatters.js`.
- No new tree sidebar component may implement its own expand/collapse state. It must wrap `TreeItem.vue`.
- `StorageBrowser.vue` is the single file browser. Do not create a parallel file grid anywhere.
- `FileManagerView` and `CloudFolderView` are thin data-providers: they feed props into `StorageBrowser` and handle emitted events. They contain no layout or grid logic of their own.
### Component architecture
```
View (thin data-provider)
└── Smart component (StorageBrowser, AdminUsersTab, etc.)
└── Dumb/presentational components (DocumentCard, FolderTreeItem, etc.)
```
- Views own stores and route params. They pass data down as props and handle emitted events.
- Smart components own layout, interactions, and internal state. They emit events upward; they do not call stores directly (exception: read-only lookups like topic color).
- Presentational components receive everything as props and emit actions.
- Props that are passed from parent to child are never mutated with `v-model` — use `:model-value` + `@update:modelValue` and emit upward.
### No dead code
- Files with no active route and no active import are deleted immediately — not commented out, not kept "just in case".
- `HomeView.vue` and `FolderView.vue` are deleted. Do not recreate them.
- Any file that becomes unreferenced after a refactor must be deleted in the same commit.
### Duplication checklist (run before writing new code)
1. Does a shared utility already exist for this logic? (Check the module map above.)
2. Does this component already exist? (Search `components/` before creating.)
3. Is this logic already in a Pinia store? (Check `stores/` before duplicating in a view.)
4. If none of the above: create the shared module first, then use it everywhere that needs it.
---
## GSD Workflow
This project uses the GSD (Get Shit Done) planning workflow. Planning artifacts live in `.planning/`.
### Key files
| File | Purpose |
|---|---|
| `.planning/ROADMAP.md` | 5-phase plan with success criteria |
| `.planning/REQUIREMENTS.md` | 54 v1 requirements with REQ-IDs |
| `.planning/STATE.md` | Current phase and completion status |
| `.planning/PROJECT.md` | Project context and key decisions |
| `.planning/research/SUMMARY.md` | Domain research synthesis |
| `.planning/codebase/` | Codebase map (architecture, stack, concerns) |
### Commands
```
/gsd:discuss-phase N — gather context before planning a phase
/gsd:plan-phase N — create execution plan for a phase
/gsd:execute-phase N — execute the plan
/gsd:verify-work N — verify phase deliverables against requirements
/gsd:progress — check status and advance workflow
```
### Current state: v0.1.5 — Phase 12 Plan 02 complete (2026-06-18)
Phase 12 Plan 02: connection-ID browse endpoint, `api/cloud/` package decomposition, `CloudResourceAdapter` mixin on all 4 providers, and `refresh_cloud_folder` Celery task with bounded retry. Stale-while-revalidate caching active. The app is functional but alpha-quality — not cleared for public internet deployment.
## Development Setup
```bash
# Start all services
docker compose up
# Backend only (local dev)
cd backend && uvicorn main:app --reload
# Frontend only (local dev)
cd frontend && npm run dev
# Run backend tests
cd backend && pytest -v
```
---
## Documentation Protocol (Non-Negotiable)
Every major development step — completing a plan, fixing a significant bug, or shipping a new feature — must end with documentation and a commit. "Major step" means any plan execution (`/gsd:execute-phase`) or standalone feature work that changes user-facing behaviour, the API surface, environment variables, or the architectural rules.
### After completing each plan or phase
1. **Update `AGENTS.md`** (this file):
- Update the "Current state" line in the GSD Workflow section to reflect what was just completed.
- Update the shared module map if new shared helpers were introduced.
- Update the code standards if new non-negotiable rules were established.
2. **Update `README.md`** if any of the following changed:
- New user-facing features (add to the Features section).
- New or changed environment variables (update the env var table).
- New cloud storage backend, AI provider, or API endpoint group.
- Changed service URLs, port numbers, or startup procedure.
- Changed version number (`backend/main.py` and `frontend/package.json` are the sources of truth).
3. **Version bump rule**: increment the patch segment of the version in `backend/main.py` and `frontend/package.json` after every plan that ships user-facing changes (e.g. `0.1.0``0.1.1`). Increment the minor segment after completing a full phase (e.g. `0.1.0``0.2.0`). Do not jump to `1.0.0` until the project owner explicitly signs off on a production-ready release.
---
## Git Protocol (Non-Negotiable)
After every major development step, save the work to the repository with an atomic commit and push. Do not accumulate multiple plan's changes into one commit — each plan gets its own commit.
### Commit sequence
```bash
# 1. Stage all changed files (be explicit — avoid -A when sensitive files may exist)
git add backend/ frontend/ AGENTS.md README.md RUNBOOK.md SECURITY.md docker-compose.yml
# 2. Commit with a descriptive message following this format:
# <type>(<scope>): <short summary>
# where type = feat | fix | docs | refactor | test | chore | security
git commit -m "feat(phase-N): short description of what was shipped"
# 3. Push to the remote repository immediately
git push origin main
```
### Commit types
| Type | When to use |
|------|-------------|
| `feat` | New user-facing feature or endpoint |
| `fix` | Bug fix (root-cause, ≤50 lines) |
| `docs` | AGENTS.md, README.md, RUNBOOK.md, or SECURITY.md only |
| `refactor` | Internal restructuring with no behaviour change |
| `test` | Adding or fixing tests only |
| `chore` | Dependencies, CI, build config |
| `security` | Security hardening, CVE fixes, auth changes |
### When to commit
| Event | Action |
|-------|--------|
| Plan execution complete (`/gsd:execute-phase`) | Commit + push immediately after tests pass |
| Documentation update (AGENTS.md / README.md) | Commit + push in the same operation as the code it documents |
| Bug fix during a phase | Commit the fix separately before continuing the plan |
| Security gate findings resolved | Commit + push before marking the phase complete |
| Version bump | Part of the plan's commit — not a separate commit |
### Commit message examples
```bash
git commit -m "feat(07-ai): GenericOpenAIProvider + Anthropic json_schema + Celery retry backoff"
git commit -m "fix(quota): atomic decrement on document delete — regression test added"
git commit -m "docs(readme): add cloud storage backend table + alpha status warning"
git commit -m "security(headers): add X-Correlation-ID + Referrer-Policy to SecurityHeadersMiddleware"
git commit -m "chore(deps): bump PyMuPDF to 1.26.7 — resolves read-only filesystem issue"
```
---
## Testing Protocol (Non-Negotiable)
Every feature, function, and bug fix requires tests. No phase or plan may advance until all tests pass.
### Rules
- **Coverage**: Every new function, endpoint, and UI component must have at least one test — unit for isolated logic, integration for DB/service boundaries, E2E for critical user flows
- **Gate**: `pytest -v` (backend) and frontend test suite must pass with zero failures before marking a plan complete or advancing to the next phase
- **Bug fixes**: Must fix the root cause, not work around it. Maximum 50 lines of changed code per fix. If a fix requires more, it is scope-creep and must be broken into a separate plan
- **No workarounds**: `# type: ignore`, `noqa`, skipping a test, or adding a `try/except` that silently swallows an error are prohibited as bug fixes
- **Regression**: Any time a bug is fixed, a test must be added that would have caught it
### Test types per layer
| Layer | Required test type |
|---|---|
| Service / business logic | Unit tests with mocked dependencies |
| DB queries / ORM | Integration tests against real PostgreSQL (not SQLite for quota/UUID tests) |
| API endpoints | `httpx.AsyncClient` integration tests with real DB fixtures |
| Auth flows | Full round-trip tests (register → login → TOTP → refresh → revoke) |
| Security invariants | Dedicated negative tests (wrong owner → 403/404, admin → 403, replay → 401) |
| Frontend | Vitest unit tests for stores/composables; Playwright or Cypress for critical flows |
---
## Security Protocol (Non-Negotiable)
A dedicated **security agent** runs after every plan execution and before any phase is marked complete. This agent has full read/write/edit access to the entire codebase and is the final gate before advancement.
### Security agent mandate
The security agent must check — and fix — every class of vulnerability listed below. It may not flag and defer; it must resolve or escalate blocking issues.
#### OWASP Top 10 + auth-specific
| Threat | Required mitigation |
|---|---|
| SQL injection | All queries via ORM or parameterized statements — zero raw string interpolation |
| XSS | CSP headers, `httpOnly` cookies, no `innerHTML` with user data, Vue template auto-escaping never bypassed |
| CSRF | `SameSite=Strict` cookie + `Origin`/`Referer` header validation on all state-changing endpoints |
| Broken auth | Short-lived JWT (≤15 min), refresh rotation, family revocation on reuse, constant-time comparison |
| IDOR / broken access control | Every resource endpoint asserts `resource.user_id == current_user.id`; admin blocked from document content |
| Security misconfiguration | No debug mode in production, no stack traces in API responses, no default credentials |
| Sensitive data exposure | Passwords hashed Argon2id, PII fields encrypted at rest, `credentials_enc` never in API responses |
| Insecure deserialization | No `pickle`, no `eval`, no dynamic `__import__`; all user-supplied data validated via Pydantic |
| Vulnerable dependencies | `pip audit` / `npm audit` run; critical/high CVEs blocked |
| Insufficient logging | All auth events, quota violations, and admin actions written to audit log without document content |
#### Advanced threats
- **Path traversal**: All file path construction uses `os.path.basename` / `pathlib` — never joins user-supplied strings directly
- **SSRF**: All outbound HTTP (HIBP, cloud OAuth) via an allowlisted client; user-supplied URLs for WebDAV/Nextcloud must pass hostname allowlist
- **Timing attacks**: `hmac.compare_digest` / `secrets.compare_digest` for all token, TOTP, and backup-code comparison — no `==`
- **Race conditions / TOCTOU**: Quota enforcement via single atomic `UPDATE … RETURNING` — never read-then-write in Python
- **Mass assignment**: Pydantic models explicitly declare every accepted field; no `**kwargs` passthrough from request body to ORM
- **Privilege escalation**: `get_regular_user` and `get_current_admin` deps checked on every endpoint; no role elevation path exists
- **Token replay**: JTI stored in DB; used TOTP codes invalidated within the 90 s window; refresh token family revocation on reuse
#### Zero-day / defense-in-depth
- **Minimal attack surface**: Every endpoint that is not needed is absent — no commented-out code, no `TODO: remove` endpoints left alive
- **Principle of least privilege**: `docuvault_app` DB role has DML only; `docuvault_migrate` has DDL; MinIO bucket policy denies public access
- **Secrets in env only**: No credentials, API keys, or signing secrets in code, commits, or `.env` files checked in; `.gitignore` enforces this
- **Dependency pinning**: `requirements.txt` and `package-lock.json` pin exact versions; no floating `>=` for security-critical packages (PyJWT, pwdlib, cryptography)
- **Container hardening**: Non-root user in Dockerfile, read-only filesystem where possible, no `--privileged` containers
- **Header hardening**: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin` on every response
### Database user table encryption
Sensitive user PII (email, display name) must be encrypted at the application layer before storage:
- Encryption: AES-256-GCM via `cryptography` library, per-row nonce, master key from env var
- Key derivation: HKDF-SHA256 with `purpose=b"user-pii"` salt — same pattern as cloud credentials
- Admin queries: never return plaintext PII for users other than the requesting user
- Indexing: email lookup uses a deterministic HMAC-SHA256 index (`email_hmac` column) — the encrypted column is never used for WHERE clauses
### Login token hardening (state of the art)
- **Algorithm**: ES256 (ECDSA P-256) — asymmetric; the private key signs, the public key verifies; a leaked public key cannot forge tokens
- **Access token TTL**: 15 minutes maximum
- **Refresh token**: 30-day httpOnly Strict cookie; rotated on every use; reuse of a rotated token revokes entire family and fires a security alert email
- **JTI claim**: Every token has a unique `jti`; revoked JTIs stored in Redis with TTL matching the token lifetime
- **Token binding**: Access token embeds a `fgp` (fingerprint) claim = HMAC of `User-Agent + Accept-Language`; backend validates on every request
- **Rotation on privilege change**: Password change, TOTP enroll/revoke, and account deactivation immediately revoke all active sessions
### Security gate checklist (must all pass before phase advances)
- [ ] `bandit -r backend/` — zero HIGH severity findings
- [ ] `pip audit` — zero critical/high CVEs
- [ ] `npm audit --audit-level=high` — zero high/critical vulnerabilities
- [ ] All security-invariant tests pass (wrong owner, admin block, token replay, CSRF)
- [ ] No new `# noqa: S` suppressions without a documented justification comment
- [ ] Admin endpoints verified to never return `password_hash`, `credentials_enc`, or document content
- [ ] No hardcoded secrets detected by `git secrets` / `trufflehog`
---
## Security Requirements (Non-Negotiable)
- Rate limiting on all auth endpoints (login, register, password reset, TOTP)
- Constant-time comparison for all token/code verification
- CSRF protection on all state-changing endpoints
- Content-Security-Policy headers on all responses
- HaveIBeenPwned API check on registration and password change
- TOTP replay prevention (mark used codes in DB within validity window)
- Refresh token family revocation on token reuse detection
- Admin impersonation is an explicit architectural exclusion — no endpoint or code path may exist
+15 -2
View File
@@ -1,6 +1,6 @@
# DocuVault # DocuVault
**Version 0.1.4 — Alpha** **Version 0.1.5 — Alpha**
> **Not production-ready.** DocuVault is functional for local and self-hosted use but has not been audited or hardened for public internet exposure. APIs, environment variables, and the database schema may change without notice until a stable 1.0 release is declared. > **Not production-ready.** DocuVault is functional for local and self-hosted use but has not been audited or hardened for public internet exposure. APIs, environment variables, and the database schema may change without notice until a stable 1.0 release is declared.
@@ -39,7 +39,7 @@ A self-hosted, multi-user document management platform with AI-powered topic cla
│ ├── api/documents.py │ │ ├── api/documents.py │
│ ├── api/folders.py │ │ ├── api/folders.py │
│ ├── api/shares.py │ │ ├── api/shares.py │
│ ├── api/cloud.py (cloud backends) │ │ ├── api/cloud/ (cloud backends package) │
│ ├── api/admin.py │ │ ├── api/admin.py │
│ └── api/audit.py │ │ └── api/audit.py │
└──┬──────────┬──────────┬───────────────┘ └──┬──────────┬──────────┬───────────────┘
@@ -303,6 +303,19 @@ Users connect cloud storage through **Settings → Cloud Storage**. Credentials
Connection statuses: `ACTIVE`, `REQUIRES_REAUTH`, `ERROR`. An externally revoked OAuth token transitions to `REQUIRES_REAUTH` without a 500 error. Connection statuses: `ACTIVE`, `REQUIRES_REAUTH`, `ERROR`. An externally revoked OAuth token transitions to `REQUIRES_REAUTH` without a 500 error.
### Connection-ID Browse API (Phase 12)
Each connected account is independently addressable by its connection UUID:
| Endpoint | Purpose |
|----------|---------|
| `GET /api/cloud/connections` | List all connections (all providers, no credentials) |
| `GET /api/cloud/connections/{id}/items` | Browse folder by connection UUID (stale-while-revalidate) |
| `PATCH /api/cloud/connections/{id}` | Rename connection display name |
| `DELETE /api/cloud/connections/{id}` | Remove connection and credentials |
Browsing returns durable cached rows immediately and schedules a background `refresh_cloud_folder` Celery task to reconcile provider changes. First-visit fetches are synchronous and bounded. All browse responses are credential-free — `credentials_enc` is never serialized in the response.
--- ---
## Security Highlights ## Security Highlights
+9 -2
View File
@@ -245,11 +245,18 @@ async def browse_connection_items(
for action in ACTIONS for action in ACTIONS
} }
# Refresh in background if first visit (no items) or stale # Refresh in background if first visit (no items), currently refreshing,
# never refreshed, or stale (last refresh > 5 minutes ago)
import datetime as _dt
_stale_threshold = _dt.timedelta(minutes=5)
_is_stale = (
folder_state.last_refreshed_at is None
or (_dt.datetime.now(_dt.timezone.utc) - folder_state.last_refreshed_at) > _stale_threshold
)
should_refresh = ( should_refresh = (
not cached_items not cached_items
or folder_state.refresh_state in ("refreshing",) or folder_state.refresh_state in ("refreshing",)
or folder_state.last_refreshed_at is None or _is_stale
) )
if should_refresh and not cached_items: if should_refresh and not cached_items:
# First visit: do a synchronous bounded fetch so user sees content # First visit: do a synchronous bounded fetch so user sees content
+2
View File
@@ -35,6 +35,7 @@ celery_app.conf.task_routes = {
"tasks.document_tasks.*": {"queue": "documents"}, "tasks.document_tasks.*": {"queue": "documents"},
"tasks.email_tasks.*": {"queue": "email"}, "tasks.email_tasks.*": {"queue": "email"},
"tasks.audit_tasks.*": {"queue": "documents"}, "tasks.audit_tasks.*": {"queue": "documents"},
"tasks.cloud_tasks.*": {"queue": "documents"},
} }
# Celery beat schedule: # Celery beat schedule:
@@ -55,5 +56,6 @@ celery_app.conf.timezone = "UTC"
# Explicitly import task modules — autodiscover_tasks(["tasks"]) looks for # Explicitly import task modules — autodiscover_tasks(["tasks"]) looks for
# tasks.tasks (appends ".tasks") which doesn't exist in our structure. # tasks.tasks (appends ".tasks") which doesn't exist in our structure.
import tasks.audit_tasks # noqa: F401, E402 import tasks.audit_tasks # noqa: F401, E402
import tasks.cloud_tasks # noqa: F401, E402
import tasks.document_tasks # noqa: F401, E402 import tasks.document_tasks # noqa: F401, E402
import tasks.email_tasks # noqa: F401, E402 import tasks.email_tasks # noqa: F401, E402
+1 -1
View File
@@ -244,7 +244,7 @@ async def lifespan(app: FastAPI):
# ── Application factory ─────────────────────────────────────────────────────── # ── Application factory ───────────────────────────────────────────────────────
app = FastAPI(title="Document Scanner API", version="0.1.4", lifespan=lifespan) app = FastAPI(title="Document Scanner API", version="0.1.5", lifespan=lifespan)
# Rate limiter state (slowapi) # Rate limiter state (slowapi)
app.state.limiter = auth_limiter app.state.limiter = auth_limiter
+212
View File
@@ -0,0 +1,212 @@
"""
Celery tasks for cloud metadata refresh in DocuVault — Phase 12.
refresh_cloud_folder — called via .delay(user_id, connection_id, parent_ref)
by the browse endpoint when durable cached rows are stale.
The task is a plain sync def (Celery workers have no asyncio event loop); it
bridges into the async service layer via asyncio.run().
Retry harness (D-13/D-14 — bounded transient retries only):
CRITICAL: self.retry() MUST be raised from the outer sync task, NOT inside
asyncio.run(). _TransientProviderError is a sentinel raised by _run() to signal
a retryable provider/network failure. The outer task catches it and calls
self.retry(countdown=...) in the sync layer.
Terminal errors (auth failure, invalid_grant, scope error):
- Written as CloudFolderState warning state with controlled reason/remedy
- NOT retried — retrying auth failures wastes network calls and may trigger
provider rate limits or OAuth token revocation
"""
from __future__ import annotations
import asyncio
import random
from celery.exceptions import MaxRetriesExceededError
from celery_app import celery_app
# ── Retry sentinels ───────────────────────────────────────────────────────────
class _TransientProviderError(Exception):
"""Raised by _run() to signal a retryable network/provider failure.
Escapes asyncio.run() and is caught by refresh_cloud_folder's sync layer,
which calls self.retry() in the Celery context (not inside asyncio.run).
"""
class _TerminalProviderError(Exception):
"""Raised by _run() for non-retryable auth/scope errors.
The task writes a warning state and does not retry.
"""
# ── Async worker ─────────────────────────────────────────────────────────────
async def _run(user_id: str, connection_id: str, parent_ref: str | None) -> dict:
"""Open a fresh DB session, revalidate owner, refresh and reconcile.
D-18/CACHE-01: never downloads file bytes, never alters quota.
D-13/D-14: on success → fresh state; on transient failure → raise sentinel
for Celery retry; on auth/scope failure → warning state + return.
"""
import uuid as _uuid
from db.session import AsyncSessionLocal
from services.cloud_items import (
ConnectionNotFound,
get_or_create_folder_state,
reconcile_cloud_listing,
resolve_owned_connection,
update_folder_state,
)
from storage.cloud_backend_factory import build_cloud_resource_adapter
from storage.cloud_utils import decrypt_credentials
from config import settings
master_key = settings.cloud_creds_key.encode()
async with AsyncSessionLocal() as session:
# Revalidate ownership — worker never trusts broker payload alone
try:
conn = await resolve_owned_connection(
session,
connection_id=connection_id,
user_id=user_id,
)
except ConnectionNotFound:
# Connection deleted or user changed — silently drop, do not retry
return {"status": "skipped", "reason": "connection_not_found"}
# Mark refreshing so the browse endpoint can show spinner state
await update_folder_state(
session,
user_id=user_id,
connection_id=connection_id,
parent_ref=parent_ref or "",
refresh_state="refreshing",
)
await session.commit()
# Decrypt credentials inside the worker — never put them in broker payload
try:
credentials = decrypt_credentials(master_key, user_id, conn.credentials_enc)
except Exception as exc:
# Decryption failure is terminal — key mismatch, corruption, etc.
await update_folder_state(
session,
user_id=user_id,
connection_id=connection_id,
parent_ref=parent_ref or "",
refresh_state="warning",
error_code="credential_error",
error_message="Credential decryption failed. Re-connect the account.",
)
await session.commit()
raise _TerminalProviderError(f"Credential decryption failed: {exc}") from exc
# Build adapter and fetch listing
try:
adapter = build_cloud_resource_adapter(conn.provider, credentials)
conn_uuid = conn.id if isinstance(conn.id, _uuid.UUID) else _uuid.UUID(str(conn.id))
user_uuid = _uuid.UUID(str(user_id))
listing = await adapter.list_folder(
conn_uuid,
user_uuid,
parent_ref=parent_ref,
)
except Exception as exc:
err_str = str(exc).lower()
# Detect auth/scope errors — these are terminal
if any(kw in err_str for kw in ("invalid_grant", "unauthorized", "401", "403", "scope", "revoked")):
await update_folder_state(
session,
user_id=user_id,
connection_id=connection_id,
parent_ref=parent_ref or "",
refresh_state="warning",
error_code="auth_error",
error_message="Authentication failed. Re-connect the account.",
)
await session.commit()
raise _TerminalProviderError(f"Auth error: {exc}") from exc
# Transient failure — let Celery retry
raise _TransientProviderError(f"Provider error: {exc}") from exc
# Reconcile listing into durable cloud_items rows
await reconcile_cloud_listing(
session,
user_id=user_id,
connection_id=connection_id,
parent_ref=parent_ref,
listing=listing,
)
await update_folder_state(
session,
user_id=user_id,
connection_id=connection_id,
parent_ref=parent_ref or "",
refresh_state="fresh",
)
await session.commit()
return {"status": "ok", "items_fetched": len(listing.items)}
# ── Celery task ───────────────────────────────────────────────────────────────
@celery_app.task(
bind=True,
max_retries=3,
name="tasks.cloud_tasks.refresh_cloud_folder",
serializer="json",
acks_late=True,
reject_on_worker_lost=True,
)
def refresh_cloud_folder(self, user_id: str, connection_id: str, parent_ref: str | None) -> dict:
"""Refresh cloud metadata for (user_id, connection_id, parent_ref) idempotently.
D-13/D-14: Bounded transient retries with increasing countdown + jitter.
Terminal auth/scope failures mark warning state without retry.
D-18/CACHE-01: Never downloads file bytes; never alters quota.
"""
try:
return asyncio.run(_run(user_id, connection_id, parent_ref))
except _TerminalProviderError:
# Already wrote warning state in _run — do not retry
return {"status": "terminal_error"}
except _TransientProviderError as exc:
try:
# Bounded exponential backoff with jitter: 30s, 90s, 270s (± 10s)
jitter = random.randint(-10, 10)
countdown = (30 * (3 ** self.request.retries)) + jitter
raise self.retry(exc=exc, countdown=countdown)
except MaxRetriesExceededError:
# Write permanent warning state after all retries exhausted
asyncio.run(_write_final_warning(user_id, connection_id, parent_ref))
return {"status": "max_retries_exceeded"}
async def _write_final_warning(
user_id: str, connection_id: str, parent_ref: str | None
) -> None:
"""Write a terminal warning state after all Celery retries are exhausted."""
from db.session import AsyncSessionLocal
from services.cloud_items import update_folder_state
async with AsyncSessionLocal() as session:
await update_folder_state(
session,
user_id=user_id,
connection_id=connection_id,
parent_ref=parent_ref or "",
refresh_state="warning",
error_code="refresh_failed",
error_message="Background refresh failed after 3 attempts. Will retry on next browse.",
)
await session.commit()
+78
View File
@@ -1081,3 +1081,81 @@ async def test_list_connections_returns_all_providers(async_client, db_session):
# credentials_enc must not be in any item # credentials_enc must not be in any item
for item in items: for item in items:
assert "credentials_enc" not in item assert "credentials_enc" not in item
async def test_browse_connection_schedules_background_refresh_on_cached_items(
async_client, db_session, monkeypatch
):
"""GET /api/cloud/connections/{id}/items schedules Celery refresh when cached items exist.
Phase 12 stale-while-revalidate: if items are already cached and folder_state
has last_refreshed_at set, the endpoint returns immediately and schedules a
background refresh via refresh_cloud_folder.delay().
Verifies: .delay() is called with the correct user_id, connection_id, parent_ref.
"""
from unittest.mock import patch, MagicMock
from db.models import CloudItem, CloudFolderState
from storage.cloud_utils import encrypt_credentials
auth = await _create_user_and_token(db_session, role="user")
uid_str = str(auth["user"].id)
master_key = b"test-key-for-testing-32bytes!!"
creds_enc = encrypt_credentials(master_key, uid_str, {"access_token": "tok"})
conn = await _create_cloud_connection(
db_session, auth["user"].id, provider="google_drive", name="Stale Drive"
)
conn_id_str = str(conn.id)
# Seed a durable CloudItem so browse sees existing rows
from datetime import datetime, timezone
item = CloudItem(
id=_uuid.uuid4(),
user_id=auth["user"].id,
connection_id=conn.id,
provider_item_id="cached-item-001",
name="cached.pdf",
kind="file",
analysis_status="pending",
semantic_index_status="none",
)
db_session.add(item)
# Seed a CloudFolderState with last_refreshed_at set (non-first-visit)
from datetime import timedelta
fs = CloudFolderState(
id=_uuid.uuid4(),
user_id=auth["user"].id,
connection_id=conn.id,
parent_ref="",
refresh_state="fresh",
last_refreshed_at=datetime.now(timezone.utc) - timedelta(minutes=5),
)
db_session.add(fs)
await db_session.commit()
delay_mock = MagicMock()
with patch("tasks.cloud_tasks.refresh_cloud_folder") as mock_task:
mock_task.delay = delay_mock
# Patch capability probe to avoid real network call
with patch("api.cloud.browse.build_cloud_resource_adapter") as mock_adapter_factory:
from storage.cloud_base import CloudCapability, STATE_SUPPORTED, ACTIONS
mock_caps = {a: CloudCapability(action=a, state=STATE_SUPPORTED) for a in ACTIONS}
mock_adapter = MagicMock()
mock_adapter.get_capabilities = _uuid.__class__ # callable stub
import asyncio as _asyncio
async def _fake_caps(*args, **kwargs):
return mock_caps
mock_adapter.get_capabilities = _fake_caps
mock_adapter_factory.return_value = mock_adapter
resp = await async_client.get(
f"/api/cloud/connections/{conn_id_str}/items",
headers=auth["headers"],
)
assert resp.status_code == 200
# Background refresh was scheduled
delay_mock.assert_called_once_with(uid_str, conn_id_str, None)
+88
View File
@@ -537,3 +537,91 @@ async def test_service_folder_state_idempotent(db_session: AsyncSession):
fs1 = await get_or_create_folder_state(db_session, user_id=uid, connection_id=cid, parent_ref="") fs1 = await get_or_create_folder_state(db_session, user_id=uid, connection_id=cid, parent_ref="")
fs2 = await get_or_create_folder_state(db_session, user_id=uid, connection_id=cid, parent_ref="") fs2 = await get_or_create_folder_state(db_session, user_id=uid, connection_id=cid, parent_ref="")
assert fs1.id == fs2.id assert fs1.id == fs2.id
# ── Task 3: refresh_cloud_folder Celery task tests ────────────────────────────
@pytest.mark.asyncio
async def test_refresh_cloud_folder_idempotent_reconciliation(db_session: AsyncSession):
"""Calling reconcile_cloud_listing twice yields no duplicate rows (idempotency check)."""
uid = await _make_user(db_session)
cid = await _make_connection(db_session, uid)
resource = _cloud_resource(cid, uid, provider_item_id="stable-001", name="stable.pdf")
listing = CloudListing(items=[resource], complete=True)
# First reconcile
await reconcile_cloud_listing(db_session, user_id=uid, connection_id=cid, parent_ref=None, listing=listing)
# Second reconcile with same data
await reconcile_cloud_listing(db_session, user_id=uid, connection_id=cid, parent_ref=None, listing=listing)
from sqlalchemy import select, func
result = await db_session.execute(
select(func.count()).select_from(CloudItem).where(
CloudItem.provider_item_id == "stable-001",
CloudItem.deleted_at.is_(None),
)
)
count = result.scalar()
assert count == 1, "Idempotent reconciliation must not create duplicate rows"
@pytest.mark.asyncio
async def test_refresh_cloud_folder_failed_refresh_retains_cached_rows(db_session: AsyncSession):
"""On provider failure, previously cached rows must not be deleted (D-13)."""
uid = await _make_user(db_session)
cid = await _make_connection(db_session, uid)
# Seed durable items
resource = _cloud_resource(cid, uid, provider_item_id="durable-001", name="important.pdf")
complete_listing = CloudListing(items=[resource], complete=True)
await reconcile_cloud_listing(db_session, user_id=uid, connection_id=cid, parent_ref=None, listing=complete_listing)
# Simulate provider failure: incomplete listing with no items
empty_failed_listing = CloudListing(items=[], complete=False)
await reconcile_cloud_listing(db_session, user_id=uid, connection_id=cid, parent_ref=None, listing=empty_failed_listing)
from sqlalchemy import select
result = await db_session.execute(
select(CloudItem).where(
CloudItem.provider_item_id == "durable-001",
CloudItem.deleted_at.is_(None),
)
)
retained = result.scalars().first()
assert retained is not None, "Incomplete listing must not delete previously cached rows"
@pytest.mark.asyncio
async def test_refresh_cloud_folder_task_structure():
"""refresh_cloud_folder is a registered Celery task with correct queue routing."""
from tasks.cloud_tasks import refresh_cloud_folder
from celery_app import celery_app
assert hasattr(refresh_cloud_folder, "delay"), "Task must have .delay() method"
assert hasattr(refresh_cloud_folder, "apply_async"), "Task must have .apply_async() method"
assert refresh_cloud_folder.name == "tasks.cloud_tasks.refresh_cloud_folder"
assert refresh_cloud_folder.max_retries == 3
routes = celery_app.conf.task_routes
assert "tasks.cloud_tasks.*" in routes
assert routes["tasks.cloud_tasks.*"]["queue"] == "documents"
@pytest.mark.asyncio
async def test_refresh_cloud_folder_no_byte_calls(db_session: AsyncSession):
"""reconcile_cloud_listing never calls get_object or any download method — D-18/CACHE-01."""
from unittest.mock import AsyncMock, patch
uid = await _make_user(db_session)
cid = await _make_connection(db_session, uid)
resource = _cloud_resource(cid, uid, provider_item_id="nobytes-001")
listing = CloudListing(items=[resource], complete=True)
mock_get_object = AsyncMock()
with patch("db.models.CloudItem", wraps=CloudItem) as _patched:
# reconcile_cloud_listing must not call any external byte-download method
await reconcile_cloud_listing(
db_session, user_id=uid, connection_id=cid, parent_ref=None, listing=listing
)
mock_get_object.assert_not_called()
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "document-scanner-frontend", "name": "document-scanner-frontend",
"version": "0.1.4", "version": "0.1.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",