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>
230 lines
7.3 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|