diff --git a/stores/__tests__/auth-store-logout-hooks.test.ts b/stores/__tests__/auth-store-logout-hooks.test.ts new file mode 100644 index 000000000..810e90e93 --- /dev/null +++ b/stores/__tests__/auth-store-logout-hooks.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import * as browserNavigation from '@/lib/browser-navigation'; +import { authHooks, clearAllHooks } from '@/lib/plugin-hooks'; +import { useAuthStore, LOGOUT_HOOK_BUDGET_MS } from '../auth-store'; +import { useAccountStore } from '../account-store'; + +describe('sign-out with plugins', () => { + beforeEach(() => { + vi.restoreAllMocks(); + clearAllHooks(); + sessionStorage.clear(); + localStorage.clear(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })); + vi.spyOn(browserNavigation, 'replaceWindowLocation').mockImplementation(() => {}); + useAccountStore.setState({ accounts: [], activeAccountId: null, defaultAccountId: null }); + useAuthStore.setState({ isAuthenticated: true, authMode: 'basic', client: null, activeAccountId: null }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('signs out even when a plugin answers false before sign-out', async () => { + const before = vi.fn().mockReturnValue(false); + const after = vi.fn(); + authHooks.onBeforeLogout.register('p', before); + authHooks.onAfterLogout.register('p', after); + + await useAuthStore.getState().logout(); + + expect(before).toHaveBeenCalledWith({ accountId: 'all' }); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + expect(after).toHaveBeenCalledWith({ accountId: 'all' }); + }); + + it('does not wait on a plugin that never answers', async () => { + vi.useFakeTimers(); + authHooks.onBeforeLogout.register('p', () => new Promise(() => {})); + + const done = useAuthStore.getState().logout(); + await vi.advanceTimersByTimeAsync(LOGOUT_HOOK_BUDGET_MS + 10); + await done; + + expect(useAuthStore.getState().isAuthenticated).toBe(false); + }); +}); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index b2c5a8b79..389a05234 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -1243,6 +1243,20 @@ function performFullLogout(set: (state: Partial) => void): void { broadcastSignOut(); } +/** + * How long sign-out waits for plugins to tidy up. Plugins observe sign-out; + * they cannot stop it or hold it up, or a plugin could keep the user signed + * in on a shared computer. + */ +export const LOGOUT_HOOK_BUDGET_MS = 2000; + +function beforeLogoutHooks(accountId: string): Promise { + return Promise.race([ + authHooks.onBeforeLogout.emit({ accountId }), + new Promise((resolve) => setTimeout(resolve, LOGOUT_HOOK_BUDGET_MS)), + ]); +} + export const useAuthStore = create()( persist( (set, get) => ({ @@ -2080,13 +2094,7 @@ export const useAuthStore = create()( const userInitiated = options?.endProviderSession !== false; const endProviderSession = userInitiated && !wasDemoMode && shouldEndProviderSession(account); - const ok = await authHooks.onBeforeLogout.intercept({ - accountId: accountId ?? 'all', - }); - - if(!ok){ - return; - } + await beforeLogoutHooks(accountId ?? 'all'); // From here on nothing may renew this slot's session; the cleanup // below reopens it once the credentials are gone. @@ -2111,7 +2119,7 @@ export const useAuthStore = create()( await useSettingsStore.getState().flushSync(); useSettingsStore.getState().disableSync(); - await authHooks.onAfterLogout.emit({ + void authHooks.onAfterLogout.emit({ accountId: accountId ?? 'all', }); @@ -2234,13 +2242,7 @@ export const useAuthStore = create()( }, logoutAll: async () => { - const ok = await authHooks.onBeforeLogout.intercept({ - accountId: 'all' - }); - - if(!ok){ - return; - } + await beforeLogoutHooks('all'); const accountStore = useAccountStore.getState(); const allAccounts = [...accountStore.accounts]; @@ -2272,7 +2274,7 @@ export const useAuthStore = create()( accountStore.removeAccount(account.id); } - await authHooks.onAfterLogout.emit({ + void authHooks.onAfterLogout.emit({ accountId: 'all' });