From 9dab77ca0dfcec34b8c30015e926d876ecf24134 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Tue, 18 Aug 2026 13:35:23 +0200 Subject: [PATCH] Fix clearing timer of SessionObserver Add tests for SessionObserver which discovered a bug in clearing a running time during unmount. --- .../components/observer/SessionObserver.tsx | 29 +++---- .../__tests__/SessionObserver.test.tsx | 86 +++++++++++++++++++ 2 files changed, 100 insertions(+), 15 deletions(-) create mode 100644 src/web/components/observer/__tests__/SessionObserver.test.tsx diff --git a/src/web/components/observer/SessionObserver.tsx b/src/web/components/observer/SessionObserver.tsx index 2bc80db11d..b879fe089b 100644 --- a/src/web/components/observer/SessionObserver.tsx +++ b/src/web/components/observer/SessionObserver.tsx @@ -3,12 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import {useCallback, useEffect} from 'react'; +import {useCallback, useEffect, useRef} from 'react'; import Logger from 'gmp/log'; import date, {type Date} from 'gmp/models/date'; import {isDefined} from 'gmp/utils/identity'; import useGmp from 'web/hooks/useGmp'; -import useInstanceVariable from 'web/hooks/useInstanceVariable'; import useSessionTimeout from 'web/hooks/useSessionTimeout'; interface PingProps { @@ -24,48 +23,48 @@ const DELAY = 5 * 1000; // 5 seconds in milliseconds const Ping = ({sessionTimeout}: PingProps) => { const gmp = useGmp(); - const [timer, setTimer] = useInstanceVariable(undefined); + const timerRef = useRef(undefined); const handlePing = useCallback(async () => { log.debug('pinging server to check session'); - setTimer(undefined); + timerRef.current = undefined; try { await gmp.user.ping(); } catch { // the session might have expired and we will get a 401 here } - }, [gmp, setTimer]); + }, [gmp]); const clearTimer = useCallback(() => { - if (isDefined(timer)) { - log.debug('clearing ping timer', timer); + if (isDefined(timerRef.current)) { + log.debug('clearing ping timer', timerRef.current); - globalThis.clearTimeout(timer); + globalThis.clearTimeout(timerRef.current); - setTimer(undefined); + timerRef.current = undefined; } - }, [timer, setTimer]); + }, []); const startTimer = useCallback(() => { - if (isDefined(timer)) { + if (isDefined(timerRef.current)) { return; } const timeout = sessionTimeout.diff(date()) + DELAY; if (timeout > 0) { - const timer = globalThis.setTimeout(handlePing, timeout); - setTimer(timer); + const timeoutId = globalThis.setTimeout(handlePing, timeout); + timerRef.current = timeoutId; log.debug( 'started ping timer', - timer, + timeoutId, 'timeout', timeout, 'milliseconds', ); } - }, [handlePing, sessionTimeout, setTimer, timer]); + }, [handlePing, sessionTimeout]); useEffect(() => { startTimer(); diff --git a/src/web/components/observer/__tests__/SessionObserver.test.tsx b/src/web/components/observer/__tests__/SessionObserver.test.tsx new file mode 100644 index 0000000000..ba2a16dae9 --- /dev/null +++ b/src/web/components/observer/__tests__/SessionObserver.test.tsx @@ -0,0 +1,86 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { + afterEach, + beforeEach, + describe, + expect, + test, + testing, +} from '@gsa/testing'; +import {render} from 'web/testing'; +import date from 'gmp/models/date'; +import SessionObserver from 'web/components/observer/SessionObserver'; + +const mockUseSessionTimeout = testing.fn(); +const ping = testing.fn(); + +vi.mock('web/hooks/useSessionTimeout', () => ({ + __esModule: true, + default: () => mockUseSessionTimeout(), +})); + +vi.mock('web/hooks/useGmp', () => ({ + __esModule: true, + default: () => ({user: {ping}}), +})); + +const now = date('2025-01-01T00:00:00Z'); +const sessionTimeout = date(now).add(1, 'minute'); + +const renderObserver = () => render(); + +describe('SessionObserver tests', () => { + beforeEach(() => { + testing.clearAllMocks(); + testing.useFakeTimers(); + testing.setSystemTime(now.toDate()); + mockUseSessionTimeout.mockReturnValue([sessionTimeout]); + ping.mockResolvedValue(undefined); + }); + + afterEach(() => { + testing.useRealTimers(); + }); + + test('should render nothing when the session timeout is not defined', () => { + mockUseSessionTimeout.mockReturnValue([undefined]); + + const {element} = renderObserver(); + + expect(element).toBeNull(); + expect(ping).not.toHaveBeenCalled(); + }); + + test('should ping after the session timeout and delay', () => { + renderObserver(); + + testing.advanceTimersByTime(60 * 1000 + 4999); + expect(ping).not.toHaveBeenCalled(); + + testing.advanceTimersByTime(1); + expect(ping).toHaveBeenCalledTimes(1); + }); + + test('should clear the ping timer when unmounted', () => { + const {unmount} = renderObserver(); + + unmount(); + testing.advanceTimersByTime(60 * 1000 + 5000); + + expect(ping).not.toHaveBeenCalled(); + }); + + test('should ignore ping errors', async () => { + ping.mockRejectedValue(new Error('session expired')); + renderObserver(); + + testing.advanceTimersByTime(60 * 1000 + 5000); + + await expect(Promise.resolve()).resolves.toBeUndefined(); + expect(ping).toHaveBeenCalledTimes(1); + }); +});