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
70 changes: 70 additions & 0 deletions web/hooks/useDocument.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,25 @@ async function resolveLocalFallbackDocument(
};
}

/**
* Fetches a document whose active access may have just been revoked (e.g. it was moved to
* trash from another tab/device). Returns the trashed copy when the caller is the owner -
* trashed documents are served read-only to their owner over REST (includeTrashed=true) -
* or null when the document is truly inaccessible (active but revoked, permanently deleted,
* or not the owner).
*/
async function loadTrashedDocumentIfVisible(
documentId: string,
token: string
): Promise<DocumentLoadResult | null> {
try {
const result = await documentService.getCloudDocument(documentId, token);
return result.meta.deletedAt ? result : null;
} catch {
return null;
}
}

export function useDocument(documentId: string, options?: UseDocumentOptions) {
const id = documentId;
const isSharedDocument = options?.isSharedDocument === true;
Expand Down Expand Up @@ -205,6 +224,29 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) {
// the module-level singleton at render time, which may be stale)
const [ydoc, setLocalYDoc] = useState<Y.Doc | null>(null);

// When the owner loses active access because their document was moved to trash
// (e.g. from another tab/device), surface the read-only trash view instead of a
// spurious "access restricted" error.
const applyTrashedDocumentView = useCallback(
(documentId: string, result: DocumentLoadResult) => {
setLocalYDoc(result.ydoc);
setYDoc(result.ydoc);
dispatch(
setCurrentDocument({
id: documentId,
meta: result.meta,
})
);
setAccessLevel('VIEW');
// The trashed doc renders read-only; don't leave a stale cached level (e.g. EDIT)
// behind that could misrepresent permissions.
clearCachedDocumentAccessLevel(documentId);
setErrorState(null);
dispatch(setError(null));
},
[dispatch]
);

useEffect(() => {
accessTokenRef.current = accessToken;
}, [accessToken]);
Expand Down Expand Up @@ -501,6 +543,11 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) {
!isOnline ||
isLoading ||
errorState !== null ||
// Trashed documents are served as a read-only, REST-only view (restore/trash UI).
// The realtime server strictly rejects access checks for trashed documents, so
// connecting would trigger a 1008 close followed by a spurious "access restricted"
// error for the document owner.
!!meta?.deletedAt ||
isCloudReadInBackoff() ||
!isAuthenticated ||
!accessTokenRef.current
Expand Down Expand Up @@ -543,6 +590,19 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) {
const myAccess = await documentService.getMyAccess(resolvedDocumentId, token);
if (closeHandlerCancelled) return;
if (!myAccess.allowed || !myAccess.accessLevel) {
// The realtime server rejected this connection (1008). Before treating it as
// a revocation, check whether the user can still view the document from trash
// (e.g. it was moved to trash from another tab/device). Trashed documents are
// served read-only to their owner over REST.
const trashedCopy = await loadTrashedDocumentIfVisible(resolvedDocumentId, token);
if (closeHandlerCancelled) return;
if (trashedCopy) {
applyTrashedDocumentView(resolvedDocumentId, trashedCopy);
provider.shouldConnect = false;
setIsRealtimeConnected(false);
setRealtimeProvider((current) => (current === provider ? null : current));
return;
}
handleAccessRevoked(404);
return;
}
Expand Down Expand Up @@ -623,12 +683,14 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) {
errorState,
isAuthenticated,
meta?.createdBy,
meta?.deletedAt,
user?.id,
user?.email,
user?.displayName,
isCloudReadInBackoff,
refresh,
dispatch,
applyTrashedDocumentView,
]);

// Listen for server-pushed access-level changes and apply them immediately.
Expand Down Expand Up @@ -725,6 +787,13 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) {
try {
const myAccess = await documentService.getMyAccess(resolvedDocumentId, accessToken);
if (!myAccess.allowed || !myAccess.accessLevel) {
// Before treating this as a revocation, check whether the user can still view
// the document from trash (e.g. it was moved to trash from another tab/device).
const trashedCopy = await loadTrashedDocumentIfVisible(resolvedDocumentId, accessToken);
if (trashedCopy) {
applyTrashedDocumentView(resolvedDocumentId, trashedCopy);
return;
}
clearCachedDocumentAccessLevel(resolvedDocumentId);
const restrictedError = buildDocumentErrorState(
new DocumentServiceApiError('The requested resource was not found.', 404)
Expand Down Expand Up @@ -783,6 +852,7 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) {
isCloudReadInBackoff,
refresh,
isRealtimeConnected,
applyTrashedDocumentView,
]);

const updateMeta = useCallback(
Expand Down
120 changes: 120 additions & 0 deletions web/tests/unit/hooks/useDocument.hook.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1306,5 +1306,125 @@ describe('useDocument', () => {
});
expect(result.current.ydoc).toBeNull();
});

it('should not establish realtime connection for a trashed document owned by the user', async () => {
const ydoc = new Y.Doc();
const trashedMeta = {
title: 'Trashed Document',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
deletedAt: '2024-01-02T00:00:00.000Z',
};

(useAuth as jest.Mock).mockReturnValue({
isAuthenticated: true,
accessToken: 'token-trashed',
isInitializing: false,
});

getCloudDocumentSpy.mockResolvedValue({ ydoc, meta: trashedMeta });

const { result } = renderHook(() => useDocument(validUuid), { wrapper: createWrapper() });

await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});

// The trashed document loads for the owner without an access check and stays open.
expect(result.current.meta?.deletedAt).toBe('2024-01-02T00:00:00.000Z');
expect(result.current.isReadOnly).toBe(true);
expect(result.current.errorState).toBeNull();
expect(result.current.ydoc).toBe(ydoc);
expect(getMyAccessSpy).not.toHaveBeenCalled();

// No realtime provider should be created for a trashed document: the realtime
// server strictly rejects access checks for trashed docs (1008 close), which would
// otherwise trigger a spurious "access restricted" error for the owner.
expect(mockOn).not.toHaveBeenCalled();
expect(registeredStatusHandler).toBeNull();
expect(registeredCloseHandler).toBeNull();
});

it('should keep a trashed document open as a read-only trash view when realtime access is revoked after the doc is trashed elsewhere', async () => {
const ydoc = new Y.Doc();
const activeMeta = {
title: 'Active Doc',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
};
const trashedMeta = {
...activeMeta,
deletedAt: '2024-01-02T00:00:00.000Z',
};

(useAuth as jest.Mock).mockReturnValue({
isAuthenticated: true,
accessToken: 'token-trashed-elsewhere',
isInitializing: false,
});

// getCloudDocument: initial load returns the active doc; the access-revocation
// re-check returns the trashed copy (owner can still view it via REST).
getCloudDocumentSpy
.mockResolvedValueOnce({ ydoc, meta: activeMeta })
.mockResolvedValueOnce({ ydoc, meta: trashedMeta });

// getMyAccess: the loadDoc check and the immediate revalidation check are allowed;
// the realtime close-handler recheck reports access revoked for the trashed doc.
getMyAccessSpy
.mockResolvedValueOnce({
documentId: validUuid,
allowed: true,
accessLevel: 'EDIT',
owner: false,
})
.mockResolvedValueOnce({
documentId: validUuid,
allowed: true,
accessLevel: 'EDIT',
owner: false,
})
.mockResolvedValueOnce({
documentId: validUuid,
allowed: false,
accessLevel: null,
owner: false,
});

const { result } = renderHook(() => useDocument(validUuid), { wrapper: createWrapper() });

await act(async () => {
for (let i = 0; i < 5; i += 1) {
await Promise.resolve();
}
});

await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});

expect(registeredCloseHandler).not.toBeNull();
expect(registeredStatusHandler).not.toBeNull();

act(() => {
registeredStatusHandler!({ status: 'connected' });
});

// Simulate the realtime server closing the connection after its periodic access
// revalidation rejects the now-trashed document.
act(() => {
registeredCloseHandler!({ code: 1008 });
});

// The owner keeps seeing the document as a read-only trash view instead of a
// spurious "access restricted" error.
await waitFor(() => {
expect(result.current.errorState).toBeNull();
});
expect(result.current.meta?.deletedAt).toBe('2024-01-02T00:00:00.000Z');
expect(result.current.accessLevel).toBe('VIEW');
expect(result.current.isReadOnly).toBe(true);
expect(result.current.ydoc).toBe(ydoc);
});
});
});