diff --git a/core/src/session-heartbeat.ts b/core/src/session-heartbeat.ts index 07ac1bb1c5790..926fcb9b9d745 100644 --- a/core/src/session-heartbeat.ts +++ b/core/src/session-heartbeat.ts @@ -123,17 +123,34 @@ function registerAutoLogout() { return } + const logoutCheckInterval = 1000 let lastActive = Date.now() - window.addEventListener('mousemove', () => { + let lastStored = 0 + const markActive = () => { lastActive = Date.now() - localStorage.setItem('lastActive', JSON.stringify(lastActive)) - }) - - window.addEventListener('touchstart', () => { - lastActive = Date.now() - localStorage.setItem('lastActive', JSON.stringify(lastActive)) - }) + if (lastActive - lastStored < logoutCheckInterval) { + return + } + lastStored = lastActive + try { + localStorage.setItem('lastActive', JSON.stringify(lastActive)) + } catch (error) { + logger.debug('Could not share the last activity with other tabs', { error }) + } + } + // Capture phase, as editors may stop the propagation of input events + window.addEventListener('mousemove', markActive, { capture: true }) + // Touch, pen and clicks, including clicks by assistive technology that do not move the mouse + window.addEventListener('pointerdown', markActive, { capture: true }) + window.addEventListener('keydown', markActive, { capture: true }) + // Text insertion without key events, e.g. dictation + window.addEventListener('beforeinput', markActive, { capture: true }) + // Wheel instead of scroll events, as scripts fire scroll events too + window.addEventListener('wheel', markActive, { capture: true, passive: true }) + + // Besides other tabs, apps with cross-origin editors (e.g. richdocuments) dispatch + // this event in the same window to report activity inside their iframes window.addEventListener('storage', (event) => { if (event.key !== 'lastActive') { return @@ -154,5 +171,5 @@ function registerAutoLogout() { window.location.href = logoutUrl } } - intervalId = window.setInterval(logoutCheck, 1000) + intervalId = window.setInterval(logoutCheck, logoutCheckInterval) } diff --git a/core/src/tests/OC/session-heartbeat.spec.ts b/core/src/tests/OC/session-heartbeat.spec.ts index 61b82d928877c..170be231bcb91 100644 --- a/core/src/tests/OC/session-heartbeat.spec.ts +++ b/core/src/tests/OC/session-heartbeat.spec.ts @@ -3,10 +3,14 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NextcloudUser } from '@nextcloud/auth' +import type { MockInstance } from 'vitest' + +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const requestToken = vi.hoisted(() => ({ fetchRequestToken: vi.fn<() => Promise>(), + getRequestToken: vi.fn<() => string>(), setRequestToken: vi.fn<(token: string) => void>(), })) vi.mock('../../OC/requesttoken.ts', () => requestToken) @@ -14,7 +18,12 @@ vi.mock('../../OC/requesttoken.ts', () => requestToken) const initialState = vi.hoisted(() => ({ loadState: vi.fn() })) vi.mock('@nextcloud/initial-state', () => initialState) +const auth = vi.hoisted(() => ({ getCurrentUser: vi.fn<() => NextcloudUser | null>(() => null) })) +vi.mock('@nextcloud/auth', () => auth) + describe('Session heartbeat', () => { + let addEventListener: MockInstance + beforeAll(() => { vi.useFakeTimers() }) @@ -23,6 +32,15 @@ describe('Session heartbeat', () => { vi.clearAllTimers() vi.resetModules() vi.resetAllMocks() + addEventListener = vi.spyOn(window, 'addEventListener') + }) + + afterEach(() => { + // Each test imports a new module instance, so its listeners must not outlive the test + for (const listener of addEventListener.mock.calls) { + window.removeEventListener(...listener) + } + vi.restoreAllMocks() }) it('sends heartbeat half the session lifetime when heartbeat enabled', async () => { @@ -120,4 +138,167 @@ describe('Session heartbeat', () => { await vi.advanceTimersByTimeAsync(60 * 60 * 1000) expect(requestToken.fetchRequestToken).toHaveBeenCalledOnce() }) + + describe('auto logout', () => { + const pageUrl = 'http://nextcloud.local/apps/text' + const logoutUrl = '/index.php/logout?requesttoken=csrf-token' + let originalLocation: Location + + beforeEach(() => { + originalLocation = window.location + Object.defineProperty(window, 'location', { + configurable: true, + writable: true, + value: { href: pageUrl }, + }) + + auth.getCurrentUser.mockReturnValue({ uid: 'user', displayName: 'User', isAdmin: false }) + requestToken.getRequestToken.mockReturnValue('csrf-token') + initialState.loadState.mockImplementationOnce(() => ({ + auto_logout: true, + session_lifetime: 120, + })) + }) + + afterEach(() => { + Object.defineProperty(window, 'location', { + configurable: true, + writable: true, + value: originalLocation, + }) + document.body.replaceChildren() + localStorage.clear() + }) + + /** + * Dispatch the activity before the session lifetime is over and + * expect the logout to happen one session lifetime after it. + * + * @param dispatchActivity - Callback dispatching the user activity + */ + async function expectActivity(dispatchActivity: () => void) { + const { initSessionHeartBeat } = await import('../../session-heartbeat.ts') + initSessionHeartBeat() + + await vi.advanceTimersByTimeAsync(100 * 1000) + dispatchActivity() + + await vi.advanceTimersByTimeAsync(120 * 1000) + expect(window.location.href).toBe(pageUrl) + + await vi.advanceTimersByTimeAsync(1000) + expect(window.location.href).toBe(logoutUrl) + } + + it('logs out once the session lifetime passed without activity', async () => { + const { initSessionHeartBeat } = await import('../../session-heartbeat.ts') + initSessionHeartBeat() + + await vi.advanceTimersByTimeAsync(120 * 1000) + expect(window.location.href).toBe(pageUrl) + + await vi.advanceTimersByTimeAsync(1000) + expect(window.location.href).toBe(logoutUrl) + }) + + it.each([ + { activity: 'mouse movement', createEvent: () => new MouseEvent('mousemove', { bubbles: true }) }, + { activity: 'touch', createEvent: () => new PointerEvent('pointerdown', { bubbles: true, pointerType: 'touch' }) }, + { activity: 'click without mouse movement', createEvent: () => new PointerEvent('pointerdown', { bubbles: true, pointerType: 'mouse' }) }, + { activity: 'key press', createEvent: () => new KeyboardEvent('keydown', { bubbles: true }) }, + { activity: 'text input', createEvent: () => new InputEvent('beforeinput', { bubbles: true, inputType: 'insertText', data: 'a' }) }, + { activity: 'wheel scrolling', createEvent: () => new WheelEvent('wheel', { bubbles: true }) }, + ])('counts $activity as activity, even when an editor stops its propagation', async ({ createEvent }) => { + const activityEvent = createEvent() + const editor = document.createElement('div') + editor.addEventListener(activityEvent.type, (event) => event.stopPropagation()) + document.body.appendChild(editor) + + await expectActivity(() => editor.dispatchEvent(activityEvent)) + }) + + it('does not delay scrolling with its wheel listener', async () => { + const { initSessionHeartBeat } = await import('../../session-heartbeat.ts') + initSessionHeartBeat() + + expect(addEventListener).toHaveBeenCalledWith('wheel', expect.any(Function), { capture: true, passive: true }) + }) + + it('counts activity reported by apps through a storage event in the same window', async () => { + await expectActivity(() => window.dispatchEvent(new StorageEvent('storage', { + key: 'lastActive', + newValue: Date.now().toString(), + }))) + }) + + it('does not count scrolling done by scripts as activity', async () => { + const element = document.createElement('div') + document.body.appendChild(element) + + const { initSessionHeartBeat } = await import('../../session-heartbeat.ts') + initSessionHeartBeat() + + await vi.advanceTimersByTimeAsync(100 * 1000) + element.dispatchEvent(new Event('scroll')) + + await vi.advanceTimersByTimeAsync(21 * 1000) + expect(window.location.href).toBe(logoutUrl) + }) + + it('stores the last activity at most once per second', async () => { + const start = Date.now() + const { initSessionHeartBeat } = await import('../../session-heartbeat.ts') + initSessionHeartBeat() + + await vi.advanceTimersByTimeAsync(100 * 1000) + document.body.dispatchEvent(new MouseEvent('mousemove', { bubbles: true })) + expect(localStorage.getItem('lastActive')).toBe(JSON.stringify(start + 100 * 1000)) + + await vi.advanceTimersByTimeAsync(999) + document.body.dispatchEvent(new MouseEvent('mousemove', { bubbles: true })) + expect(localStorage.getItem('lastActive')).toBe(JSON.stringify(start + 100 * 1000)) + + await vi.advanceTimersByTimeAsync(1) + document.body.dispatchEvent(new MouseEvent('mousemove', { bubbles: true })) + expect(localStorage.getItem('lastActive')).toBe(JSON.stringify(start + 101 * 1000)) + }) + + it('stores the first activity right after the page load', async () => { + const start = Date.now() + const { initSessionHeartBeat } = await import('../../session-heartbeat.ts') + initSessionHeartBeat() + + await vi.advanceTimersByTimeAsync(500) + document.body.dispatchEvent(new MouseEvent('mousemove', { bubbles: true })) + expect(localStorage.getItem('lastActive')).toBe(JSON.stringify(start + 500)) + }) + + it('counts activity that is not stored for other tabs yet', async () => { + const { initSessionHeartBeat } = await import('../../session-heartbeat.ts') + initSessionHeartBeat() + + await vi.advanceTimersByTimeAsync(99500) + document.body.dispatchEvent(new MouseEvent('mousemove', { bubbles: true })) + await vi.advanceTimersByTimeAsync(999) + document.body.dispatchEvent(new MouseEvent('mousemove', { bubbles: true })) + + // The logout for the stored activity would be due at 220 s, the one for the latest activity at 221 s + await vi.advanceTimersByTimeAsync(220000 - 100499) + expect(window.location.href).toBe(pageUrl) + + await vi.advanceTimersByTimeAsync(1000) + expect(window.location.href).toBe(logoutUrl) + }) + + it('counts activity when the browser storage is not available', async () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('The quota has been exceeded.', 'QuotaExceededError') + }) + const onError = vi.fn() + window.addEventListener('error', onError) + + await expectActivity(() => document.body.dispatchEvent(new MouseEvent('mousemove', { bubbles: true }))) + expect(onError).not.toHaveBeenCalled() + }) + }) })