Skip to content
Open
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
76 changes: 76 additions & 0 deletions playwright/e2e/session-rejected.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
8 changes: 6 additions & 2 deletions src/components/CollaborativeEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -728,14 +728,18 @@ 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)
this.$emit('push:forbidden')
showWarning(t(
'text',
'Your editing permissions have been revoked. The document is now read-only.',
))
this.$emit('push:forbidden')
return
}

Expand Down
11 changes: 9 additions & 2 deletions src/services/SyncService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
Expand Down Expand Up @@ -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()) {
Expand Down
Loading