diff --git a/frontend/src/helpers/auth.test.ts b/frontend/src/helpers/auth.test.ts index 69af2335f..46fc9232c 100644 --- a/frontend/src/helpers/auth.test.ts +++ b/frontend/src/helpers/auth.test.ts @@ -49,6 +49,16 @@ describe('refreshToken in-flight dedup', () => { }) it('coalesces concurrent calls into a single POST when Web Locks is available', async () => { + // Stub a minimal Web Locks API: happy-dom leaves navigator.locks + // undefined, so without this the test would silently fall through to + // the insecure-HTTP branch and never exercise navigator.locks.request. + const requestSpy = vi.fn((_name: string, cb: () => unknown) => cb()) + Object.defineProperty(navigator, 'locks', { + value: {request: requestSpy}, + configurable: true, + writable: true, + }) + const p1 = refreshToken(true) const p2 = refreshToken(true) @@ -58,6 +68,9 @@ describe('refreshToken in-flight dedup', () => { settlePost() await Promise.all([p1, p2]) + // The Web Locks branch actually ran... + expect(requestSpy).toHaveBeenCalledWith('vikunja-token-refresh', expect.any(Function)) + // ...and the in-flight dedup still collapsed both calls into one POST. expect(postCallCount).toBe(1) }) diff --git a/frontend/src/stores/auth.renewToken.test.ts b/frontend/src/stores/auth.renewToken.test.ts index d5bedc098..331f86856 100644 --- a/frontend/src/stores/auth.renewToken.test.ts +++ b/frontend/src/stores/auth.renewToken.test.ts @@ -57,6 +57,18 @@ function refreshError() { }) } +// A JWT carrying a not-yet-expired user session, so the checkAuth() call that +// renewToken() runs after a successful refresh treats the session as live. +function freshUserJwt() { + const payload = { + id: 1, + type: AUTH_TYPES.USER, + exp: Math.floor(Date.now() / 1000) + 3600, + } + const encoded = btoa(JSON.stringify(payload)) + return `header.${encoded}.signature` +} + describe('auth store renewToken retry (issue #2863)', () => { beforeEach(() => { setActivePinia(createPinia()) @@ -79,15 +91,23 @@ describe('auth store renewToken retry (issue #2863)', () => { const store = useAuthStore() setupExpiredUserSession(store) + // The retry "succeeds" only if it actually leaves a usable token behind: + // renewToken() runs checkAuth() afterwards, which reads getToken(). Start + // with no token, then hand back a fresh JWT once the refresh resolves. + getTokenMock.mockReturnValue(null) refreshTokenMock .mockRejectedValueOnce(refreshError()) - .mockResolvedValueOnce(undefined) + .mockImplementationOnce(async () => { + getTokenMock.mockReturnValue(freshUserJwt()) + }) await store.renewToken() // Two refresh attempts: the initial one and the single retry. expect(refreshTokenMock).toHaveBeenCalledTimes(2) - // The retry succeeded, so the user is not bounced to login. + // The retry recovered the session: the user is still authenticated... + expect(store.authenticated).toBe(true) + // ...and was not bounced to login. expect(routerPushMock).not.toHaveBeenCalledWith({name: 'user.login'}) })