Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions .planning/debug/e2e-quota-fetch-race.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
---
status: diagnosed
trigger: 'e2e test failing after adding useEffect fetchQuota to StorageQuota component'
created: 2026-02-10T00:00:00Z
updated: 2026-02-10T00:01:00Z
symptoms_prefilled: true
goal: find_root_cause_only
---

## Current Focus

hypothesis: CONFIRMED - fetchQuota fires before auth token is available after page reload, racing with session restoration's refresh token call; with server-side token rotation one refresh fails, and if the interceptor's fails it calls logout()
test: Traced full code path through 8 source files
expecting: N/A - root cause confirmed
next_action: Return diagnosis

## Symptoms

expected: e2e test full-workflow.spec.ts passes after adding useEffect fetchQuota to StorageQuota
actual: e2e test reportedly failing in CI after the change
errors: Likely 401 on GET /vault/quota triggering refresh race -> logout -> session destroyed
reproduction: Run e2e test full-workflow.spec.ts, observe test 3.7+ after page.reload()
started: After adding useEffect(() => { fetchQuota(); }, [fetchQuota]) to StorageQuota.tsx

## Eliminated

## Evidence

- timestamp: 2026-02-10T00:00:10Z
checked: VaultController (apps/api/src/vault/vault.controller.ts)
found: @UseGuards(JwtAuthGuard) at controller level, GET /vault/quota requires valid JWT
implication: Any request to /vault/quota without a valid access token returns 401

- timestamp: 2026-02-10T00:00:15Z
checked: StorageQuota.tsx and AppSidebar.tsx
found: StorageQuota fires fetchQuota() unconditionally on mount via useEffect with no auth check
implication: If StorageQuota mounts before auth is ready, fetchQuota sends unauthenticated request

- timestamp: 2026-02-10T00:00:20Z
checked: FilesPage.tsx loading state (lines 24-29)
found: Loading state renders <AppShell> which includes <AppSidebar> -> <StorageQuota>
implication: StorageQuota mounts and fires fetchQuota DURING auth restoration, before token is available

- timestamp: 2026-02-10T00:00:25Z
checked: routes/index.tsx
found: No route-level auth guard; /files renders FilesPage directly
implication: Auth protection is component-level only (useAuth hook in FilesPage)

- timestamp: 2026-02-10T00:00:30Z
checked: API client interceptor (apps/web/src/lib/api/client.ts lines 27-77)
found: 401 interceptor creates refreshPromise to POST /auth/refresh; on refresh FAILURE the catch handler calls logout() clearing ALL stores (lines 57-65)
implication: If fetchQuota's 401 -> refresh fails, the app logs out completely

- timestamp: 2026-02-10T00:00:35Z
checked: useAuth.ts restoreSession (lines 341-453)
found: Session restoration also calls authApi.refresh() (line 385 for E2E, line 413 for normal). Both paths go through apiClient which uses the same refresh cookie.
implication: Two concurrent POST /auth/refresh calls race against each other

- timestamp: 2026-02-10T00:00:40Z
checked: TokenService.rotateRefreshToken (apps/api/src/auth/services/token.service.ts lines 48-94)
found: Server uses refresh token rotation - old token is revoked (line 89: validToken.revokedAt = new Date()) before new tokens are created (line 93)
implication: Two concurrent refresh calls using the same token: first succeeds and revokes token, second fails with UnauthorizedException('Invalid refresh token')

- timestamp: 2026-02-10T00:00:45Z
checked: E2E test setup (playwright.config.ts, test files)
found: No **e2e_test_mode** flag set by tests. Tests use real Web3Auth login flow.
implication: After reload, normal (non-E2E) session restoration path applies

- timestamp: 2026-02-10T00:00:50Z
checked: auth.store.ts
found: accessToken stored in memory only (not localStorage), wiped on page reload
implication: After page.reload() in test 3.7, accessToken is null until session restoration completes

## Resolution

root_cause: |
After page.reload() in test 3.7, the StorageQuota component mounts (rendered by AppShell
even during FilesPage's loading state) and immediately fires fetchQuota() via useEffect.
At this point, accessToken is null (Zustand memory-only store was wiped by reload).

The request chain is:

1. fetchQuota() -> GET /vault/quota (no auth header, token is null)
2. Server returns 401 (JwtAuthGuard rejects)
3. Axios 401 interceptor fires -> POST /auth/refresh (using valid refresh cookie)
4. Meanwhile, useAuth's restoreSession also fires -> POST /auth/refresh (same cookie)
5. Server uses refresh token rotation (token.service.ts line 89): first call succeeds
and REVOKES the old token, second call fails with 'Invalid refresh token'
6. If the interceptor's refresh call is the one that fails (loses the race), its .catch()
handler (client.ts lines 57-65) calls useAuthStore.getState().logout(), clearing ALL
stores and destroying the session
7. All subsequent e2e tests fail because the user is logged out

The race is non-deterministic but biased: useAuth's restoreSession effect typically fires
after StorageQuota's effect (React processes child effects first), but the interceptor's
refresh is triggered only after the GET /vault/quota round-trip returns 401. By that time,
useAuth's refresh may already be in-flight or completed, having rotated the token.

fix: |
Guard fetchQuota() in StorageQuota with an auth check. Only fetch when there is an
authenticated session with an access token available:

In apps/web/src/components/layout/StorageQuota.tsx:

```tsx
import { useEffect } from 'react';
import { useQuotaStore } from '../../stores/quota.store';
import { useAuthStore } from '../../stores/auth.store';

export function StorageQuota() {
const { usedBytes, limitBytes, fetchQuota } = useQuotaStore();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);

useEffect(() => {
if (isAuthenticated) {
fetchQuota();
}
}, [fetchQuota, isAuthenticated]);
// ... rest unchanged
}
```

This ensures fetchQuota() only fires once isAuthenticated is true (i.e., after session
restoration has set the access token). No 401, no refresh race, no accidental logout.

verification:
files_changed: []
70 changes: 70 additions & 0 deletions .planning/debug/resolved/storage-quota-stuck-zero.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
---
status: resolved
trigger: 'Storage quota display stays stuck at zero after re-login, even after file tree has loaded into state.'
created: 2026-02-10T00:00:00Z
updated: 2026-02-10T00:02:00Z
---

## Current Focus

hypothesis: CONFIRMED and FIXED - fetchQuota() was never called on login or app initialization.
test: Added useEffect to StorageQuota component to call fetchQuota() on mount
expecting: Quota now syncs from backend whenever the component mounts (i.e., on every login/page load)
next_action: Archive session

## Symptoms

expected: After logging back in, the storage quota display should reflect the actual used storage (including files uploaded in previous sessions). If a user uploaded 5MB of files, the quota bar/number should show ~5MB used.
actual: The quota display stays at zero after re-login, even after the current folder file tree has been loaded into application state. The file tree shows the files exist, but the quota doesn't account for them.
errors: Unknown - user doesn't have browser console access right now.
reproduction: 1) Log in, 2) Upload files, 3) Observe quota updates correctly within session, 4) Log out, 5) Log back in, 6) File tree loads and shows files, but quota stays at zero.
started: Works within a session. Broken on re-login. Unclear if it ever worked correctly across sessions.

## Eliminated

(No false hypotheses - root cause was identified on first hypothesis)

## Evidence

- timestamp: 2026-02-10T00:00:30Z
checked: All call sites of fetchQuota in apps/web/src
found: fetchQuota is called in exactly 4 places - all upload-related:
1. apps/web/src/services/upload.service.ts:134 (after upload completes)
2. apps/web/src/hooks/useFileUpload.ts:62 (before starting upload)
3. apps/web/src/components/file-browser/EmptyState.tsx:85 (after upload in empty state)
4. apps/web/src/components/file-browser/UploadZone.tsx:127 (after upload in drop zone)
implication: No code path fetches quota on login, session restore, or app initialization

- timestamp: 2026-02-10T00:00:40Z
checked: quota.store.ts initial state
found: usedBytes defaults to 0, limitBytes defaults to 500*1024*1024. Store is zustand (in-memory), so resets to defaults on page reload / new session.
implication: Without calling fetchQuota(), usedBytes will always be 0 after login

- timestamp: 2026-02-10T00:00:45Z
checked: useAuth.ts login() and restoreSession() flows
found: Neither login() nor restoreSession() call fetchQuota(). Login does steps 1-8 (Web3Auth -> backend auth -> vault init -> navigate). Session restore does refresh + vault load. Neither touches quota store.
implication: Confirmed root cause - quota is never synced from backend on login

- timestamp: 2026-02-10T00:00:50Z
checked: StorageQuota.tsx component
found: Component only reads from store (usedBytes, limitBytes) - has no useEffect to fetch on mount
implication: Component is a pure display component, never triggers its own data fetch

- timestamp: 2026-02-10T00:00:55Z
checked: Backend GET /vault/quota endpoint (vault.controller.ts)
found: Endpoint exists, is guarded by JWT, calls vaultService.getQuota(userId). Returns QuotaResponseDto with usedBytes, limitBytes, remainingBytes.
implication: Backend is ready - the frontend just never calls it on init

- timestamp: 2026-02-10T00:01:10Z
checked: Backend vaultService.getQuota() implementation
found: Computes quota from database (SUM of pinned_cid.size_bytes), not in-memory. Persistent across sessions.
implication: Backend correctly returns cumulative usage from all sessions - only the frontend call was missing

## Resolution

root_cause: fetchQuota() is never called on login, session restore, or app initialization. The quota store initializes with usedBytes=0 (zustand in-memory default). The only code paths that call fetchQuota() are upload-related (before/after upload). So within a session, the in-memory addUsage() calls keep the display updated during uploads, but on re-login the store resets to zero and nothing triggers a backend sync.
fix: Added useEffect to StorageQuota component that calls fetchQuota() on mount. Since the component only renders inside authenticated routes (FilesPage/SettingsPage via AppShell), this ensures the quota is always synced from the backend when the user sees the quota display.
verification: ESLint passes. All 22 existing tests pass (4 test files). The useEffect dependency [fetchQuota] is stable (zustand function reference) so it fires exactly once on mount. No regressions.
files_changed:

- apps/web/src/components/layout/StorageQuota.tsx
17 changes: 16 additions & 1 deletion apps/web/src/components/layout/StorageQuota.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useEffect } from 'react';
import { useAuthStore } from '../../stores/auth.store';
import { useQuotaStore } from '../../stores/quota.store';

/**
Expand All @@ -18,9 +20,22 @@ function formatBytes(bytes: number): string {
/**
* Storage quota indicator component.
* Shows storage usage with a progress bar and text.
* Fetches current quota from the backend on mount.
*/
export function StorageQuota() {
const { usedBytes, limitBytes } = useQuotaStore();
const { usedBytes, limitBytes, fetchQuota } = useQuotaStore();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);

// Fetch quota from backend once authenticated so the display reflects
// actual usage (e.g. files uploaded in previous sessions).
// Guarded by isAuthenticated to avoid firing before session restoration
// completes — an unauthenticated request would race with the refresh
// token flow and could trigger an accidental logout.
useEffect(() => {
if (isAuthenticated) {
fetchQuota();
}
}, [fetchQuota, isAuthenticated]);

// Calculate percentage (avoid division by zero)
const percentage = limitBytes > 0 ? (usedBytes / limitBytes) * 100 : 0;
Expand Down
23 changes: 23 additions & 0 deletions tests/e2e/tests/full-workflow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,17 @@ test.describe.serial('Full Workflow', () => {
}
});

test('3.6.1 Storage quota reflects uploaded files', async () => {
// After uploading 13+ files, the quota display should show non-zero usage.
// The StorageQuota component fetches from GET /vault/quota which computes
// usage from pinned CIDs in the database.
const quotaText = page.locator('[data-testid="storage-quota"] .storage-quota-text');
await expect(quotaText).toBeVisible({ timeout: 10000 });

// Quota should NOT be "0 B" — files were uploaded so usage must be > 0
await expect(quotaText).not.toHaveText(/^0 B\s*\//, { timeout: 15000 });
});

// ============================================
// Phase 3.5: Post-Reload Subfolder Navigation
// ============================================
Expand Down Expand Up @@ -405,6 +416,18 @@ test.describe.serial('Full Workflow', () => {
expect(await fileList.isItemVisible(editableFileName)).toBe(true);
});

test('3.7.1 Storage quota persists after page reload', async () => {
// After reload, the Zustand store is wiped so quota resets to 0.
// The StorageQuota component should fetch fresh quota from the backend
// once auth is restored, showing the same non-zero value as before reload.
// This is a regression test for the quota-stuck-at-zero bug.
const quotaText = page.locator('[data-testid="storage-quota"] .storage-quota-text');
await expect(quotaText).toBeVisible({ timeout: 10000 });

// Quota should NOT be "0 B" — the backend still has the pinned CIDs
await expect(quotaText).not.toHaveText(/^0 B\s*\//, { timeout: 15000 });
});

test('3.8 Navigate into subfolder after reload and verify contents', async () => {
// Double-click workspace folder → exercises navigateTo cold-load
await navigateIntoFolder(workspaceFolder);
Expand Down
Loading