diff --git a/playwright/e2e/session-rejected.spec.ts b/playwright/e2e/session-rejected.spec.ts new file mode 100644 index 00000000000..d2ba73b972a --- /dev/null +++ b/playwright/e2e/session-rejected.spec.ts @@ -0,0 +1,76 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Route } from '@playwright/test' + +import { expect, mergeTests } from '@playwright/test' +import { test as editorTest } from '../support/fixtures/editor.ts' +import { test as uploadFileTest } from '../support/fixtures/upload-file.ts' + +const test = mergeTests(editorTest, uploadFileTest) + +// Only sync and push are rejected. `create` stays reachable so that +// reconnecting can open a fresh session. +const SESSION_REQUESTS = /\/apps\/text\/session\/\d+\/(sync|push)$/ + +// The awareness heartbeat pushes every 15 seconds. +// Waiting longer than that proves that the client stopped pushing. +const HEARTBEAT_TIMEOUT = 20_000 + +test.beforeEach(async ({ open }) => { + await open() +}) + +test('stops syncing and offers to reconnect once the session is rejected', async ({ + editor, + page, +}) => { + test.slow() + await expect(editor.el).toBeVisible() + await expect(editor.sessionList).toBeVisible() + + const status = page.locator('.document-status') + const content = editor.el.locator('.ProseMirror') + await expect(content).toHaveAttribute('contenteditable', 'true') + + // Answer like SessionMiddleware does for a session it no longer knows. + let pushCount = 0 + const rejectSession = async (route: Route) => { + if (route.request().url().endsWith('/push')) { + pushCount++ + } + await route.fulfill({ + status: 403, + contentType: 'application/json', + body: '[]', + }) + } + + await page.route(SESSION_REQUESTS, rejectSession) + + // Typing triggers a push right away instead of waiting for the heartbeat. + await editor.type('Hello') + + await expect(status).toContainText("You've been disconnected from the server.") + await expect(status.getByRole('button', { name: 'Reconnect' })).toBeVisible() + await expect(content).toHaveAttribute('contenteditable', 'false') + + const pushesUntilRejected = pushCount + await new Promise((resolve) => setTimeout(resolve, HEARTBEAT_TIMEOUT)) + expect(pushCount).toBe(pushesUntilRejected) + + await page.unroute(SESSION_REQUESTS, rejectSession) + const createRequest = page.waitForRequest(/\/apps\/text\/session\/\d+\/create$/) + await status.getByRole('button', { name: 'Reconnect' }).click() + await createRequest + + await expect(status).not.toContainText("You've been disconnected from the server.") + await expect(editor.sessionList).toBeVisible() + await expect(content).toHaveAttribute('contenteditable', 'true') + await expect(editor.content).toContainText('Hello') + + await editor.press('Enter') + await editor.typeHeading('Back again') +}) diff --git a/src/components/CollaborativeEditor.vue b/src/components/CollaborativeEditor.vue index 3ceefab14a4..5fe10dca3fe 100644 --- a/src/components/CollaborativeEditor.vue +++ b/src/components/CollaborativeEditor.vue @@ -424,7 +424,7 @@ export default defineComponent({ }, displayed() { - return (this.connection && this.active) || this.syncError + return (this.active && (this.connection || this.idle)) || this.syncError }, showLoadingSkeleton() { @@ -728,13 +728,13 @@ export default defineComponent({ } if (type === ERROR_TYPE.PUSH_FORBIDDEN) { + // Server rejected the session. Behave like an idle disconnect: + // read-only editor with a permanent status card offering to reconnect. + this.idle = true + this.hasConnectionIssues = false this.readOnly = true this.editMode = false this.setEditable(this.editMode) - showWarning(t( - 'text', - 'Your editing permissions have been revoked. The document is now read-only.', - )) this.$emit('push:forbidden') return } diff --git a/src/services/SyncService.ts b/src/services/SyncService.ts index 4f91c318c39..b5d6c441073 100644 --- a/src/services/SyncService.ts +++ b/src/services/SyncService.ts @@ -280,8 +280,10 @@ class SyncService { data: response, }) } else if (response?.status === 403) { - // either the session is invalid or the document is read only. - logger.error('failed to write to document - not allowed') + // The server no longer accepts this session (expired, document reset or access revoked). + // Stop syncing instead of retrying with a dead session. + logger.error('Failed to push steps - session is no longer valid') + this.invalidateSession() this.bus.emit('error', { type: ERROR_TYPE.PUSH_FORBIDDEN, data: {}, @@ -333,6 +335,11 @@ class SyncService { return this.sendStepsNow().catch((err) => logger.error(err)) } + invalidateSession() { + this.backend?.disconnect() + this.connection.value = undefined + } + async close() { this.backend?.disconnect() if (this.hasActiveConnection()) {