Files
Stalwart-webui/src/stores/authStore.test.ts
T
Steven RYDELLandClaude Sonnet 5 bd00b4e922 fix: persist active account across reload, remount views on switch
Aligns with upstream stalwartlabs/webui@189e270 (v1.0.7, "fixes #17"),
reviewed after they independently landed a fix for the same issue:

- authStore now persists activeAccountId (sessionStorage) and
  setSession preserves it across a session refresh instead of always
  resetting to primaryAccountId, so a hard reload keeps you on the
  group account you had selected instead of bouncing back to your own.
- switchAccount now clears cacheStore (displayNames/objectLists) when
  actually changing account, since those were resolved against the
  previous account and would otherwise show stale labels.
- The ErrorBoundary wrapping MainContent is now keyed on
  activeAccountId, forcing a full remount of every view on switch.
  This is more robust than gating individual components' fetch
  effects on activeAccountId (our earlier fix in DynamicList.tsx,
  kept as-is — harmless now, but no longer load-bearing on its own)
  since it covers every current and future view type, not just lists.

Verified against a live instance: switching to a group account updates
the JMAP accountId immediately (no tab switch needed), and a full page
reload keeps the group account active instead of resetting to admin.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 17:48:19 +02:00

230 lines
7.3 KiB
TypeScript

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useAuthStore } from './authStore';
import { useCacheStore } from './cacheStore';
const initialState = {
accessToken: null,
refreshToken: null,
tokenExpiresAt: null,
tokenEndpoint: null,
accounts: {},
primaryAccountId: null,
activeAccountId: null,
apiUrl: null,
};
describe('authStore', () => {
beforeEach(() => {
useAuthStore.setState(initialState);
useCacheStore.setState({ displayNames: {}, objectLists: {} });
vi.restoreAllMocks();
});
describe('setTokens', () => {
it('stores access and refresh tokens', () => {
useAuthStore.getState().setTokens('acc-123', 'ref-456', 3600, 'https://auth/token');
const state = useAuthStore.getState();
expect(state.accessToken).toBe('acc-123');
expect(state.refreshToken).toBe('ref-456');
});
it('computes expiration time from expiresIn', () => {
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now);
useAuthStore.getState().setTokens('a', 'r', 3600, 'https://auth/token');
expect(useAuthStore.getState().tokenExpiresAt).toBe(now + 3600 * 1000);
});
it('stores token endpoint', () => {
useAuthStore.getState().setTokens('a', 'r', 3600, 'https://example.com/token');
expect(useAuthStore.getState().tokenEndpoint).toBe('https://example.com/token');
});
});
describe('isAuthenticated', () => {
it('returns false when no token is set', () => {
expect(useAuthStore.getState().isAuthenticated()).toBe(false);
});
it('returns true when token exists and is not expired', () => {
const now = Date.now();
useAuthStore.setState({
accessToken: 'tok',
tokenExpiresAt: now + 60_000,
});
expect(useAuthStore.getState().isAuthenticated()).toBe(true);
});
it('returns false when token is expired', () => {
useAuthStore.setState({
accessToken: 'tok',
tokenExpiresAt: Date.now() - 1000,
});
expect(useAuthStore.getState().isAuthenticated()).toBe(false);
});
it('returns false when accessToken is null even if expiry is in the future', () => {
useAuthStore.setState({
accessToken: null,
tokenExpiresAt: Date.now() + 60_000,
});
expect(useAuthStore.getState().isAuthenticated()).toBe(false);
});
});
describe('isTokenExpiringSoon', () => {
it('returns false when tokenExpiresAt is null', () => {
expect(useAuthStore.getState().isTokenExpiringSoon()).toBe(false);
});
it('returns true within 60 seconds of expiration', () => {
useAuthStore.setState({ tokenExpiresAt: Date.now() + 30_000 });
expect(useAuthStore.getState().isTokenExpiringSoon()).toBe(true);
});
it('returns false when more than 60 seconds remain', () => {
useAuthStore.setState({ tokenExpiresAt: Date.now() + 120_000 });
expect(useAuthStore.getState().isTokenExpiringSoon()).toBe(false);
});
it('returns true when token is already expired', () => {
useAuthStore.setState({ tokenExpiresAt: Date.now() - 5000 });
expect(useAuthStore.getState().isTokenExpiringSoon()).toBe(true);
});
});
describe('setSession', () => {
it('stores accounts and primaryAccountId', () => {
const accounts = {
'acc-1': { name: 'Personal', isPersonal: true },
'acc-2': { name: 'Work', isPersonal: false },
};
useAuthStore.getState().setSession(accounts, 'acc-1', 'https://api');
const state = useAuthStore.getState();
expect(state.accounts).toEqual(accounts);
expect(state.primaryAccountId).toBe('acc-1');
expect(state.apiUrl).toBe('https://api');
});
it('sets activeAccountId to primaryAccountId', () => {
useAuthStore.getState().setSession({ 'acc-1': { name: 'A', isPersonal: true } }, 'acc-1', 'https://api');
expect(useAuthStore.getState().activeAccountId).toBe('acc-1');
});
it('preserves the previously active account across a reload if still valid', () => {
useAuthStore.setState({ activeAccountId: 'acc-2' });
const accounts = {
'acc-1': { name: 'Personal', isPersonal: true },
'acc-2': { name: 'Shared', isPersonal: false },
};
useAuthStore.getState().setSession(accounts, 'acc-1', 'https://api');
expect(useAuthStore.getState().activeAccountId).toBe('acc-2');
});
it('falls back to primaryAccountId if the previously active account is gone', () => {
useAuthStore.setState({ activeAccountId: 'acc-removed' });
useAuthStore.getState().setSession({ 'acc-1': { name: 'A', isPersonal: true } }, 'acc-1', 'https://api');
expect(useAuthStore.getState().activeAccountId).toBe('acc-1');
});
});
describe('switchAccount', () => {
it('changes activeAccountId when account exists', () => {
useAuthStore.setState({
accounts: {
a1: { name: 'A1', isPersonal: true },
a2: { name: 'A2', isPersonal: false },
},
activeAccountId: 'a1',
});
useAuthStore.getState().switchAccount('a2');
expect(useAuthStore.getState().activeAccountId).toBe('a2');
});
it('does nothing for unknown account id', () => {
useAuthStore.setState({
accounts: { a1: { name: 'A1', isPersonal: true } },
activeAccountId: 'a1',
});
useAuthStore.getState().switchAccount('nonexistent');
expect(useAuthStore.getState().activeAccountId).toBe('a1');
});
it('clears the cache store when switching to a different account', () => {
useAuthStore.setState({
accounts: {
a1: { name: 'A1', isPersonal: true },
a2: { name: 'A2', isPersonal: false },
},
activeAccountId: 'a1',
});
useCacheStore.getState().setDisplayNames('x:Domain', { d1: 'example.org' });
useAuthStore.getState().switchAccount('a2');
expect(useCacheStore.getState().displayNames).toEqual({});
});
it('does not clear the cache store when "switching" to the already-active account', () => {
useAuthStore.setState({
accounts: { a1: { name: 'A1', isPersonal: true } },
activeAccountId: 'a1',
});
useCacheStore.getState().setDisplayNames('x:Domain', { d1: 'example.org' });
useAuthStore.getState().switchAccount('a1');
expect(useCacheStore.getState().displayNames).toEqual({ 'x:Domain': { d1: 'example.org' } });
});
});
describe('logout', () => {
it('clears all state', () => {
useAuthStore.setState({
accessToken: 'tok',
refreshToken: 'ref',
tokenExpiresAt: 99999,
tokenEndpoint: 'https://auth/token',
accounts: { a: { name: 'A', isPersonal: true } },
primaryAccountId: 'a',
activeAccountId: 'a',
apiUrl: 'https://api',
});
useAuthStore.getState().logout();
const state = useAuthStore.getState();
expect(state.accessToken).toBeNull();
expect(state.refreshToken).toBeNull();
expect(state.tokenExpiresAt).toBeNull();
expect(state.tokenEndpoint).toBeNull();
expect(state.accounts).toEqual({});
expect(state.primaryAccountId).toBeNull();
expect(state.activeAccountId).toBeNull();
expect(state.apiUrl).toBeNull();
});
});
});