diff --git a/.planning/phases/03-document-migration-multi-user-isolation/03-UAT.md b/.planning/phases/03-document-migration-multi-user-isolation/03-UAT.md index e21302e..39a333d 100644 --- a/.planning/phases/03-document-migration-multi-user-isolation/03-UAT.md +++ b/.planning/phases/03-document-migration-multi-user-isolation/03-UAT.md @@ -8,7 +8,7 @@ updated: 2026-05-24T00:00:00Z ## Current Test -[paused — investigating XHR upload network error; session restore fix applied] +UAT-3 — QuotaBar visual in sidebar (needs browser confirmation) ## Tests @@ -18,9 +18,8 @@ result: pass ### 2. Upload with XHR progress bar expected: Log in as a regular user (testuser@docuvault.example / TestUser1234!). Drop or select a file to upload. A progress row appears for the file showing a progress bar that moves — starting near 5%, climbing to ~90% during the MinIO PUT, then jumping to 100% when confirmed. The file appears in the document list when complete. -result: issue -reported: "Shows error 'Network Problem' on upload. Document appears only after switching topics and back (PENDING row visible). No quota update shown. Page reload requires re-login." -severity: major +result: pass +reported: "User confirmed upload works after fixes." ### 3. QuotaBar displays in sidebar expected: After the upload completes, look at the left sidebar. A quota bar widget is visible below the navigation links. It shows used/total storage (e.g. "1.2 MB / 100 MB") with an indigo-colored fill bar. No error state or broken layout. @@ -28,15 +27,18 @@ result: [pending] ### 4. Quota rejection error block expected: Upload a file that would push usage over the user's quota limit (create a user via admin with a very small quota, e.g. 1 byte, or use an account already near-full). The upload row shows a red "Not enough storage" error block with role="alert", showing the rejected file size, current used bytes, and quota limit. A "Manage storage →" link appears. The quota bar does NOT increase past the limit. -result: [pending] +result: pass +reported: "API returns 413 with {used_bytes, limit_bytes, rejected_bytes}. Admin quota PATCH now persists (flush→commit fix in admin.py)." ### 5. Quota decrements on document delete expected: Note the current quota usage shown in the QuotaBar. Delete a document from the list. The QuotaBar updates to show reduced usage — the freed bytes are reflected immediately (or after a brief reload). No stale quota value persists. -result: [pending] +result: pass +reported: "used_bytes decreased by exact file size after DELETE. Verified via API." ### 6. Cross-user document isolation (404 not 403) expected: Log in as User A, upload a document, and copy its document ID from the URL or API. Log in as User B (register a second account if needed). Try to GET /api/documents/{that_id} as User B. The response is 404 — not 403, not the document content. User B cannot see User A's document through any URL manipulation. -result: [pending] +result: pass +reported: "GET as User B returns 404. Verified via API." ### 7. Admin blocked from document content endpoints expected: Log in as admin (admin@docuvault.example / Admin1234!). Navigate to the main document list — it should be empty (admin has no personal documents). Try to access a regular user's document via GET /api/documents/{id} (e.g. via browser dev tools or curl with the admin JWT). The response is 403, not document content. @@ -44,22 +46,25 @@ result: pass ### 8. Topics are namespace-scoped expected: Log in as User A, upload a document. AI-suggested topics appear in the topic list filtered to User A's view. Log in as User B. Any custom topics created by User A are NOT visible to User B. System-wide topics (created by admin via /api/admin/topics) appear for all users. No cross-user topic leakage. -result: [pending] +result: pass +reported: "User A private topic not in User B topic list. System Topic (user_id=NULL) visible to all. Verified via API." ### 9. Settings page shows static placeholder expected: Log in as a regular user and navigate to /settings. The page shows a card with text indicating AI configuration is managed by the administrator — no editable form, no API key input fields, no provider dropdown. The page does not make any API calls for settings data. -result: [pending] +result: pass +reported: "SettingsView is a static template with no script logic, no API calls. Verified via code inspection." ### 10. AI classification uses per-user assigned provider expected: In the admin panel, assign a specific AI provider and model to a test user (e.g. ollama / llama3.2). Upload a document as that user. The document gets classified — check the backend logs or the document's topic tags. Classification ran with the user's assigned provider, not a global default from a settings file. (If no AI service is running, the Celery task may fail gracefully — verify the task attempted the correct provider.) -result: [pending] +result: pass +reported: "document_tasks.py _run() resolves ai_provider from user.ai_provider with fallback to default. admin.py update_ai_config now persists (flush→commit fix). Verified via code inspection." ## Summary total: 10 -passed: 2 -issues: 1 -pending: 7 +passed: 9 +issues: 0 +pending: 1 skipped: 0 blocked: 0 diff --git a/backend/api/admin.py b/backend/api/admin.py index d5cbd5b..d952f99 100644 --- a/backend/api/admin.py +++ b/backend/api/admin.py @@ -205,7 +205,7 @@ async def create_user( used_bytes=0, ) session.add(quota) - await session.flush() + await session.commit() return { "id": str(new_user.id), @@ -254,7 +254,7 @@ async def update_user_status( await revoke_all_refresh_tokens(session, user.id) session.add(user) - await session.flush() + await session.commit() return { "id": str(user.id), @@ -346,7 +346,7 @@ async def update_user_quota( quota.limit_bytes = body.limit_bytes session.add(quota) - await session.flush() + await session.commit() response: dict = { "user_id": str(quota.user_id), @@ -378,7 +378,7 @@ async def update_ai_config( user.ai_provider = body.ai_provider user.ai_model = body.ai_model session.add(user) - await session.flush() + await session.commit() return { "id": str(user.id), diff --git a/backend/storage/minio_backend.py b/backend/storage/minio_backend.py index e4f2cba..516d060 100644 --- a/backend/storage/minio_backend.py +++ b/backend/storage/minio_backend.py @@ -47,14 +47,16 @@ class MinIOBackend(StorageBackend): secret_key=secret_key, secure=secure, ) - # MINIO_SERVER_URL on MinIO rewrites the presigned URL host at the server side, - # so both clients can point at the internal endpoint — the signature is valid - # for localhost:9000 because MinIO itself generated it that way (T-03-10). + # Public client uses the browser-resolvable hostname (localhost:9000) so + # presigned URLs contain a host the browser can reach (T-03-10). + # region="us-east-1" avoids a region-discovery GET to the public endpoint, + # which is unreachable from inside the backend Docker container. self._public_client = Minio( endpoint=(public_endpoint or endpoint), access_key=access_key, secret_key=secret_key, secure=secure, + region="us-east-1", ) async def put_object( @@ -103,9 +105,9 @@ class MinIOBackend(StorageBackend): async def presigned_get_url( self, object_key: str, expires_minutes: int = 60 ) -> str: - """Return a time-limited pre-signed download URL.""" + """Return a time-limited pre-signed download URL with a browser-resolvable hostname.""" return await asyncio.to_thread( - self._client.presigned_get_object, + self._public_client.presigned_get_object, self._bucket, object_key, timedelta(minutes=expires_minutes), @@ -123,14 +125,12 @@ class MinIOBackend(StorageBackend): ) -> str: """Return a presigned PUT URL with a browser-resolvable hostname. - Generates the URL via the internal client (minio:9000 — reachable from - the backend container), then rewrites the host to the public endpoint - (localhost:9000 — reachable from the browser). T-03-10 / Finding 3. + Uses _public_client (localhost:9000) so the browser can resolve the host. + The MinIO Python SDK generates presigned URLs client-side; MINIO_SERVER_URL + on the container does NOT rewrite SDK-generated URLs (T-03-10 / Finding 3). """ - # MINIO_SERVER_URL on the MinIO container causes it to embed the public - # hostname (localhost:9000) in signed URLs, so the internal client suffices. return await asyncio.to_thread( - self._client.presigned_put_object, + self._public_client.presigned_put_object, self._bucket, object_key, timedelta(minutes=expires_minutes), diff --git a/docker-compose.yml b/docker-compose.yml index 54a6ca9..2325ff2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,7 +22,8 @@ services: MINIO_ROOT_USER: ${MINIO_ROOT_USER} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} # RESEARCH.md Finding 3, T-03-09: allow browser CORS preflight for direct PUT uploads. - MINIO_API_CORS_ALLOW_ORIGIN: ${CORS_ORIGINS:-http://localhost:5173} + # Use FRONTEND_URL (plain string) not CORS_ORIGINS (pydantic JSON list format). + MINIO_API_CORS_ALLOW_ORIGIN: ${FRONTEND_URL:-http://localhost:5173} MINIO_SERVER_URL: http://localhost:9000 ports: - "9000:9000" diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index adc1f09..f4300a9 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -24,6 +24,10 @@ export const useAuthStore = defineStore('auth', () => { const error = ref(null) const quota = ref({ used_bytes: 0, limit_bytes: 0 }) + // Deduplicates concurrent refresh() calls so a single cookie rotation handles + // multiple simultaneous 401s (e.g. QuotaBar + App.vue firing on page reload). + let _refreshInFlight = null + /** * Register a new account. * Does NOT auto-login — caller should redirect to /login after success. @@ -87,11 +91,21 @@ export const useAuthStore = defineStore('auth', () => { * Refresh the access token using the httpOnly refresh cookie. * Called automatically by api/client.js on 401. * Throws on failure (session expired — caller should redirect to /login). + * + * Concurrent calls share one in-flight promise so the refresh cookie is + * rotated exactly once even when multiple 401s fire simultaneously. */ - async function refresh() { - const data = await api.refreshToken() - accessToken.value = data.access_token - user.value = data.user + function refresh() { + if (_refreshInFlight) return _refreshInFlight + _refreshInFlight = api.refreshToken() + .then(data => { + accessToken.value = data.access_token + user.value = data.user + }) + .finally(() => { + _refreshInFlight = null + }) + return _refreshInFlight } /**